From 29d66f39ea5650e136d65cd3fd47823951fa53bf Mon Sep 17 00:00:00 2001 From: Stephen MacVicar Date: Mon, 12 Feb 2024 09:22:21 -0500 Subject: [PATCH] initial commit --- .dockerignore | 22 + .env | 2 + .env.development | 3 + .env.production | 3 + .env.test | 2 + .github/workflows/ruby.yml | 54 + .gitignore | 20 + .rspec | 1 + .rubocop.yml | 87 + .ruby-version | 1 + .tool-versions | 1 + Dockerfile | 24 + Gemfile | 21 + Gemfile.lock | 364 + LICENSE | 201 + NOTICE.md | 11 + Procfile | 2 + README.md | 133 + Rakefile | 15 + config.ru | 11 + config/database.yml | 18 + config/nginx.background.conf | 87 + config/nginx.conf | 102 + .../presets/inferno_crd_client_suite.json.erb | 102 + .../presets/inferno_crd_server_suite.json.erb | 173 + .../presets/ri_crd_request_generator.json.erb | 74 + config/presets/ri_crd_server.json.erb | 108 + config/puma.rb | 2 + data/.keep | 0 data/redis/.keep | 0 davinci_crd_test_kit.gemspec | 25 + docker-compose.background.yml | 36 + docker-compose.yml | 35 + docs/crd-client-shalls.md | 165 + docs/crd-server-shalls.md | 169 + docs/crd-testing-notes.md | 187 + lib/davinci_crd_test_kit.rb | 2 + .../companions_prerequisites.json | 58 + .../create_update_coverage_information.json | 20 + .../card_responses/external_reference.json | 21 + .../card_responses/instructions.json | 14 + .../card_responses/launch_smart_app.json | 21 + .../propose_alternate_request.json | 71 + .../request_form_completion.json | 227 + lib/davinci_crd_test_kit/cards_validation.rb | 234 + .../client_fhir_api_group.rb | 762 +++ .../client_hook_request_validation.rb | 15 + .../client_hooks_group.rb | 706 ++ .../appointment_book_receive_request_test.rb | 71 + .../client_display_cards_attest.rb | 48 + .../client_fhir_api_create_test.rb | 40 + .../client_tests/client_fhir_api_read_test.rb | 39 + .../client_fhir_api_search_test.rb | 232 + .../client_fhir_api_update_test.rb | 40 + .../client_fhir_api_validation_test.rb | 60 + .../client_tests/decode_auth_token_test.rb | 40 + ...ncounter_discharge_receive_request_test.rb | 68 + .../encounter_start_receive_request_test.rb | 68 + .../hook_request_optional_fields_test.rb | 41 + .../hook_request_required_fields_test.rb | 40 + .../hook_request_valid_context_test.rb | 63 + .../hook_request_valid_prefetch_test.rb | 151 + .../order_dispatch_receive_request_test.rb | 79 + .../order_select_receive_request_test.rb | 76 + .../order_sign_receive_request_test.rb | 79 + .../client_tests/retrieve_jwks_test.rb | 65 + .../client_tests/token_header_test.rb | 34 + .../client_tests/token_payload_test.rb | 61 + lib/davinci_crd_test_kit/crd_client_suite.rb | 156 + lib/davinci_crd_test_kit/crd_jwks.json | 59 + lib/davinci_crd_test_kit/crd_options.rb | 9 + lib/davinci_crd_test_kit/crd_server_suite.rb | 115 + .../ext/inferno_core/runnable.rb | 22 + .../hook_request_field_validation.rb | 410 ++ lib/davinci_crd_test_kit/igs/.keep | 0 .../igs/davinci-crd-2.0.1.tgz | Bin 0 -> 319024 bytes lib/davinci_crd_test_kit/jwks.rb | 25 + lib/davinci_crd_test_kit/jwt_helper.rb | 74 + .../mock_service_response.rb | 421 ++ .../routes/cds-services.json | 74 + .../routes/cds_services_discovery_handler.rb | 18 + .../routes/hook_request_endpoint.rb | 93 + .../routes/jwk_set_endpoint_handler.rb | 15 + .../server_appointment_book_group.rb | 173 + .../server_discovery_group.rb | 59 + .../server_encounter_discharge_group.rb | 144 + .../server_encounter_start_group.rb | 144 + .../server_hook_request_validation.rb | 15 + .../server_hooks_group.rb | 69 + .../server_order_dispatch_group.rb | 173 + .../server_order_select_group.rb | 169 + .../server_order_sign_group.rb | 198 + ...required_card_response_validation_group.rb | 23 + .../additional_orders_validation_test.rb | 70 + .../card_optional_fields_validation_test.rb | 47 + ...tem_action_across_hooks_validation_test.rb | 32 + ...information_system_action_received_test.rb | 58 + ...formation_system_action_validation_test.rb | 121 + ..._coverage_info_response_validation_test.rb | 72 + .../server_tests/discovery_endpoint_test.rb | 88 + .../discovery_services_validation_test.rb | 65 + ...rence_card_across_hooks_validation_test.rb | 28 + ...external_reference_card_validation_test.rb | 36 + ...orm_completion_response_validation_test.rb | 79 + ...uctions_card_received_across_hooks_test.rb | 25 + .../instructions_card_received_test.rb | 28 + .../launch_smart_app_card_validation_test.rb | 38 + ..._alternate_request_card_validation_test.rb | 65 + .../server_tests/service_call_test.rb | 86 + ...service_request_context_validation_test.rb | 30 + ...request_optional_fields_validation_test.rb | 41 + ...request_required_fields_validation_test.rb | 43 + .../service_response_validation_test.rb | 82 + .../suggestion_actions_validation.rb | 123 + lib/davinci_crd_test_kit/tags.rb | 8 + lib/davinci_crd_test_kit/test_helper.rb | 23 + lib/davinci_crd_test_kit/urls.rb | 52 + lib/davinci_crd_test_kit/version.rb | 3 + resources/crd_transaction_bundle.json | 6094 +++++++++++++++++ run.sh | 3 + setup.sh | 4 + .../additional_orders_validation_test_spec.rb | 62 + ...ointment_book_receive_request_test_spec.rb | 284 + ...rd_optional_fields_validation_test_spec.rb | 311 + .../client_fhir_api_create_test_spec.rb | 236 + .../client_fhir_api_read_test_spec.rb | 179 + .../client_fhir_api_search_test_spec.rb | 853 +++ .../client_fhir_api_update_test_spec.rb | 270 + .../client_fhir_api_validation_test_spec.rb | 280 + ...ction_across_hooks_validation_test_spec.rb | 51 + ...mation_system_action_received_test_spec.rb | 62 + ...tion_system_action_validation_test_spec.rb | 144 + ...rage_info_response_validation_test_spec.rb | 92 + .../decode_auth_token_test_spec.rb | 90 + ...ter_discharge_receive_request_test_spec.rb | 287 + ...counter_start_receive_request_test_spec.rb | 286 + ..._card_across_hooks_validation_test_spec.rb | 47 + ...nal_reference_card_validation_test_spec.rb | 48 + ...ompletion_response_validation_test_spec.rb | 86 + .../hook_request_optional_fields_test_spec.rb | 235 + .../hook_request_required_fields_test_spec.rb | 147 + ...alid_context_test_appointment_book_spec.rb | 410 ++ ..._valid_context_test_encounter_hook_spec.rb | 347 + ..._valid_context_test_order_dispatch_spec.rb | 397 ++ ...st_valid_context_test_order_select_spec.rb | 479 ++ ...uest_valid_context_test_order_sign_spec.rb | 450 ++ .../hook_request_valid_prefetch_test_spec.rb | 478 ++ ...ns_card_received_across_hooks_test_spec.rb | 47 + .../instructions_card_received_test_spec.rb | 49 + spec/davinci_crd_test_kit/jwt_helper_spec.rb | 57 + ...nch_smart_app_card_validation_test_spec.rb | 48 + ...rder_dispatch_receive_request_test_spec.rb | 305 + .../order_select_receive_request_test_spec.rb | 275 + .../order_sign_receive_request_test_spec.rb | 282 + ...rnate_request_card_validation_test_spec.rb | 97 + .../retrieve_jwks_test_spec.rb | 130 + .../cds_services_discovery_handler_spec.rb | 26 + .../server_discovery_group_spec.rb | 156 + .../service_call_test_spec.rb | 100 + ...ce_request_context_validation_test_spec.rb | 431 ++ .../service_response_validation_test_spec.rb | 338 + .../token_header_test_spec.rb | 80 + .../token_payload_test_spec.rb | 148 + .../appointment_book_hook_request.json | 119 + .../crd_authorization_hook_response.json | 248 + spec/fixtures/crd_coverage_example.json | 60 + spec/fixtures/crd_encounter_example.json | 93 + spec/fixtures/crd_location_example.json | 57 + spec/fixtures/crd_patient_example.json | 121 + spec/fixtures/crd_practitioner_example.json | 48 + .../fixtures/crd_service_request_example.json | 39 + spec/fixtures/crd_task_example.json | 85 + .../encounter_discharge_hook_request.json | 17 + .../encounter_start_hook_request.json | 17 + .../fixtures/order_dispatch_hook_request.json | 44 + spec/fixtures/order_select_context.json | 334 + spec/fixtures/order_select_hook_request.json | 187 + spec/fixtures/order_sign_hook_request.json | 186 + spec/fixtures/other_system_action.json | 49 + spec/fixtures/valid_cards.json | 511 ++ spec/request_helper.rb | 26 + spec/spec_helper.rb | 141 + worker.rb | 3 + 183 files changed, 27474 insertions(+) create mode 100644 .dockerignore create mode 100644 .env create mode 100644 .env.development create mode 100644 .env.production create mode 100644 .env.test create mode 100644 .github/workflows/ruby.yml create mode 100644 .gitignore create mode 100644 .rspec create mode 100644 .rubocop.yml create mode 100644 .ruby-version create mode 100644 .tool-versions create mode 100644 Dockerfile create mode 100644 Gemfile create mode 100644 Gemfile.lock create mode 100644 LICENSE create mode 100644 NOTICE.md create mode 100644 Procfile create mode 100644 README.md create mode 100644 Rakefile create mode 100644 config.ru create mode 100644 config/database.yml create mode 100644 config/nginx.background.conf create mode 100644 config/nginx.conf create mode 100644 config/presets/inferno_crd_client_suite.json.erb create mode 100644 config/presets/inferno_crd_server_suite.json.erb create mode 100644 config/presets/ri_crd_request_generator.json.erb create mode 100644 config/presets/ri_crd_server.json.erb create mode 100644 config/puma.rb create mode 100644 data/.keep create mode 100644 data/redis/.keep create mode 100644 davinci_crd_test_kit.gemspec create mode 100644 docker-compose.background.yml create mode 100644 docker-compose.yml create mode 100644 docs/crd-client-shalls.md create mode 100644 docs/crd-server-shalls.md create mode 100644 docs/crd-testing-notes.md create mode 100644 lib/davinci_crd_test_kit.rb create mode 100644 lib/davinci_crd_test_kit/card_responses/companions_prerequisites.json create mode 100644 lib/davinci_crd_test_kit/card_responses/create_update_coverage_information.json create mode 100644 lib/davinci_crd_test_kit/card_responses/external_reference.json create mode 100644 lib/davinci_crd_test_kit/card_responses/instructions.json create mode 100644 lib/davinci_crd_test_kit/card_responses/launch_smart_app.json create mode 100644 lib/davinci_crd_test_kit/card_responses/propose_alternate_request.json create mode 100644 lib/davinci_crd_test_kit/card_responses/request_form_completion.json create mode 100644 lib/davinci_crd_test_kit/cards_validation.rb create mode 100644 lib/davinci_crd_test_kit/client_fhir_api_group.rb create mode 100644 lib/davinci_crd_test_kit/client_hook_request_validation.rb create mode 100644 lib/davinci_crd_test_kit/client_hooks_group.rb create mode 100644 lib/davinci_crd_test_kit/client_tests/appointment_book_receive_request_test.rb create mode 100644 lib/davinci_crd_test_kit/client_tests/client_display_cards_attest.rb create mode 100644 lib/davinci_crd_test_kit/client_tests/client_fhir_api_create_test.rb create mode 100644 lib/davinci_crd_test_kit/client_tests/client_fhir_api_read_test.rb create mode 100644 lib/davinci_crd_test_kit/client_tests/client_fhir_api_search_test.rb create mode 100644 lib/davinci_crd_test_kit/client_tests/client_fhir_api_update_test.rb create mode 100644 lib/davinci_crd_test_kit/client_tests/client_fhir_api_validation_test.rb create mode 100644 lib/davinci_crd_test_kit/client_tests/decode_auth_token_test.rb create mode 100644 lib/davinci_crd_test_kit/client_tests/encounter_discharge_receive_request_test.rb create mode 100644 lib/davinci_crd_test_kit/client_tests/encounter_start_receive_request_test.rb create mode 100644 lib/davinci_crd_test_kit/client_tests/hook_request_optional_fields_test.rb create mode 100644 lib/davinci_crd_test_kit/client_tests/hook_request_required_fields_test.rb create mode 100644 lib/davinci_crd_test_kit/client_tests/hook_request_valid_context_test.rb create mode 100644 lib/davinci_crd_test_kit/client_tests/hook_request_valid_prefetch_test.rb create mode 100644 lib/davinci_crd_test_kit/client_tests/order_dispatch_receive_request_test.rb create mode 100644 lib/davinci_crd_test_kit/client_tests/order_select_receive_request_test.rb create mode 100644 lib/davinci_crd_test_kit/client_tests/order_sign_receive_request_test.rb create mode 100644 lib/davinci_crd_test_kit/client_tests/retrieve_jwks_test.rb create mode 100644 lib/davinci_crd_test_kit/client_tests/token_header_test.rb create mode 100644 lib/davinci_crd_test_kit/client_tests/token_payload_test.rb create mode 100644 lib/davinci_crd_test_kit/crd_client_suite.rb create mode 100644 lib/davinci_crd_test_kit/crd_jwks.json create mode 100644 lib/davinci_crd_test_kit/crd_options.rb create mode 100644 lib/davinci_crd_test_kit/crd_server_suite.rb create mode 100644 lib/davinci_crd_test_kit/ext/inferno_core/runnable.rb create mode 100644 lib/davinci_crd_test_kit/hook_request_field_validation.rb create mode 100644 lib/davinci_crd_test_kit/igs/.keep create mode 100644 lib/davinci_crd_test_kit/igs/davinci-crd-2.0.1.tgz create mode 100644 lib/davinci_crd_test_kit/jwks.rb create mode 100644 lib/davinci_crd_test_kit/jwt_helper.rb create mode 100644 lib/davinci_crd_test_kit/mock_service_response.rb create mode 100644 lib/davinci_crd_test_kit/routes/cds-services.json create mode 100644 lib/davinci_crd_test_kit/routes/cds_services_discovery_handler.rb create mode 100644 lib/davinci_crd_test_kit/routes/hook_request_endpoint.rb create mode 100644 lib/davinci_crd_test_kit/routes/jwk_set_endpoint_handler.rb create mode 100644 lib/davinci_crd_test_kit/server_appointment_book_group.rb create mode 100644 lib/davinci_crd_test_kit/server_discovery_group.rb create mode 100644 lib/davinci_crd_test_kit/server_encounter_discharge_group.rb create mode 100644 lib/davinci_crd_test_kit/server_encounter_start_group.rb create mode 100644 lib/davinci_crd_test_kit/server_hook_request_validation.rb create mode 100644 lib/davinci_crd_test_kit/server_hooks_group.rb create mode 100644 lib/davinci_crd_test_kit/server_order_dispatch_group.rb create mode 100644 lib/davinci_crd_test_kit/server_order_select_group.rb create mode 100644 lib/davinci_crd_test_kit/server_order_sign_group.rb create mode 100644 lib/davinci_crd_test_kit/server_required_card_response_validation_group.rb create mode 100644 lib/davinci_crd_test_kit/server_tests/additional_orders_validation_test.rb create mode 100644 lib/davinci_crd_test_kit/server_tests/card_optional_fields_validation_test.rb create mode 100644 lib/davinci_crd_test_kit/server_tests/coverage_information_system_action_across_hooks_validation_test.rb create mode 100644 lib/davinci_crd_test_kit/server_tests/coverage_information_system_action_received_test.rb create mode 100644 lib/davinci_crd_test_kit/server_tests/coverage_information_system_action_validation_test.rb create mode 100644 lib/davinci_crd_test_kit/server_tests/create_or_update_coverage_info_response_validation_test.rb create mode 100644 lib/davinci_crd_test_kit/server_tests/discovery_endpoint_test.rb create mode 100644 lib/davinci_crd_test_kit/server_tests/discovery_services_validation_test.rb create mode 100644 lib/davinci_crd_test_kit/server_tests/external_reference_card_across_hooks_validation_test.rb create mode 100644 lib/davinci_crd_test_kit/server_tests/external_reference_card_validation_test.rb create mode 100644 lib/davinci_crd_test_kit/server_tests/form_completion_response_validation_test.rb create mode 100644 lib/davinci_crd_test_kit/server_tests/instructions_card_received_across_hooks_test.rb create mode 100644 lib/davinci_crd_test_kit/server_tests/instructions_card_received_test.rb create mode 100644 lib/davinci_crd_test_kit/server_tests/launch_smart_app_card_validation_test.rb create mode 100644 lib/davinci_crd_test_kit/server_tests/propose_alternate_request_card_validation_test.rb create mode 100644 lib/davinci_crd_test_kit/server_tests/service_call_test.rb create mode 100644 lib/davinci_crd_test_kit/server_tests/service_request_context_validation_test.rb create mode 100644 lib/davinci_crd_test_kit/server_tests/service_request_optional_fields_validation_test.rb create mode 100644 lib/davinci_crd_test_kit/server_tests/service_request_required_fields_validation_test.rb create mode 100644 lib/davinci_crd_test_kit/server_tests/service_response_validation_test.rb create mode 100644 lib/davinci_crd_test_kit/suggestion_actions_validation.rb create mode 100644 lib/davinci_crd_test_kit/tags.rb create mode 100644 lib/davinci_crd_test_kit/test_helper.rb create mode 100644 lib/davinci_crd_test_kit/urls.rb create mode 100644 lib/davinci_crd_test_kit/version.rb create mode 100644 resources/crd_transaction_bundle.json create mode 100755 run.sh create mode 100755 setup.sh create mode 100644 spec/davinci_crd_test_kit/additional_orders_validation_test_spec.rb create mode 100644 spec/davinci_crd_test_kit/appointment_book_receive_request_test_spec.rb create mode 100644 spec/davinci_crd_test_kit/card_optional_fields_validation_test_spec.rb create mode 100644 spec/davinci_crd_test_kit/client_fhir_api_create_test_spec.rb create mode 100644 spec/davinci_crd_test_kit/client_fhir_api_read_test_spec.rb create mode 100644 spec/davinci_crd_test_kit/client_fhir_api_search_test_spec.rb create mode 100644 spec/davinci_crd_test_kit/client_fhir_api_update_test_spec.rb create mode 100644 spec/davinci_crd_test_kit/client_fhir_api_validation_test_spec.rb create mode 100644 spec/davinci_crd_test_kit/coverage_information_system_action_across_hooks_validation_test_spec.rb create mode 100644 spec/davinci_crd_test_kit/coverage_information_system_action_received_test_spec.rb create mode 100644 spec/davinci_crd_test_kit/coverage_information_system_action_validation_test_spec.rb create mode 100644 spec/davinci_crd_test_kit/create_or_update_coverage_info_response_validation_test_spec.rb create mode 100644 spec/davinci_crd_test_kit/decode_auth_token_test_spec.rb create mode 100644 spec/davinci_crd_test_kit/encounter_discharge_receive_request_test_spec.rb create mode 100644 spec/davinci_crd_test_kit/encounter_start_receive_request_test_spec.rb create mode 100644 spec/davinci_crd_test_kit/external_reference_card_across_hooks_validation_test_spec.rb create mode 100644 spec/davinci_crd_test_kit/external_reference_card_validation_test_spec.rb create mode 100644 spec/davinci_crd_test_kit/form_completion_response_validation_test_spec.rb create mode 100644 spec/davinci_crd_test_kit/hook_request_optional_fields_test_spec.rb create mode 100644 spec/davinci_crd_test_kit/hook_request_required_fields_test_spec.rb create mode 100644 spec/davinci_crd_test_kit/hook_request_valid_context_test_appointment_book_spec.rb create mode 100644 spec/davinci_crd_test_kit/hook_request_valid_context_test_encounter_hook_spec.rb create mode 100644 spec/davinci_crd_test_kit/hook_request_valid_context_test_order_dispatch_spec.rb create mode 100644 spec/davinci_crd_test_kit/hook_request_valid_context_test_order_select_spec.rb create mode 100644 spec/davinci_crd_test_kit/hook_request_valid_context_test_order_sign_spec.rb create mode 100644 spec/davinci_crd_test_kit/hook_request_valid_prefetch_test_spec.rb create mode 100644 spec/davinci_crd_test_kit/instructions_card_received_across_hooks_test_spec.rb create mode 100644 spec/davinci_crd_test_kit/instructions_card_received_test_spec.rb create mode 100644 spec/davinci_crd_test_kit/jwt_helper_spec.rb create mode 100644 spec/davinci_crd_test_kit/launch_smart_app_card_validation_test_spec.rb create mode 100644 spec/davinci_crd_test_kit/order_dispatch_receive_request_test_spec.rb create mode 100644 spec/davinci_crd_test_kit/order_select_receive_request_test_spec.rb create mode 100644 spec/davinci_crd_test_kit/order_sign_receive_request_test_spec.rb create mode 100644 spec/davinci_crd_test_kit/propose_alternate_request_card_validation_test_spec.rb create mode 100644 spec/davinci_crd_test_kit/retrieve_jwks_test_spec.rb create mode 100644 spec/davinci_crd_test_kit/routes/cds_services_discovery_handler_spec.rb create mode 100644 spec/davinci_crd_test_kit/server_discovery_group_spec.rb create mode 100644 spec/davinci_crd_test_kit/service_call_test_spec.rb create mode 100644 spec/davinci_crd_test_kit/service_request_context_validation_test_spec.rb create mode 100644 spec/davinci_crd_test_kit/service_response_validation_test_spec.rb create mode 100644 spec/davinci_crd_test_kit/token_header_test_spec.rb create mode 100644 spec/davinci_crd_test_kit/token_payload_test_spec.rb create mode 100644 spec/fixtures/appointment_book_hook_request.json create mode 100644 spec/fixtures/crd_authorization_hook_response.json create mode 100644 spec/fixtures/crd_coverage_example.json create mode 100644 spec/fixtures/crd_encounter_example.json create mode 100644 spec/fixtures/crd_location_example.json create mode 100644 spec/fixtures/crd_patient_example.json create mode 100644 spec/fixtures/crd_practitioner_example.json create mode 100644 spec/fixtures/crd_service_request_example.json create mode 100644 spec/fixtures/crd_task_example.json create mode 100644 spec/fixtures/encounter_discharge_hook_request.json create mode 100644 spec/fixtures/encounter_start_hook_request.json create mode 100644 spec/fixtures/order_dispatch_hook_request.json create mode 100644 spec/fixtures/order_select_context.json create mode 100644 spec/fixtures/order_select_hook_request.json create mode 100644 spec/fixtures/order_sign_hook_request.json create mode 100644 spec/fixtures/other_system_action.json create mode 100644 spec/fixtures/valid_cards.json create mode 100644 spec/request_helper.rb create mode 100644 spec/spec_helper.rb create mode 100644 worker.rb diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..aaf9ca5 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,22 @@ +.env.local +.env.*.local +.env.development +.env.test +Dockerfile +.gitignore + +.byebug_history +**/.DS_Store +.vscode/* +.project +.settings/.jsdtscope +.settings/org.eclipse.wst.jsdt.ui.superType.container +.settings/org.eclipse.wst.jsdt.ui.superType.name +.idea + +/coverage +/data +/.git +/.github +/log +/tmp diff --git a/.env b/.env new file mode 100644 index 0000000..974bf6c --- /dev/null +++ b/.env @@ -0,0 +1,2 @@ +JS_HOST="" +RI_CRD_REQUEST_GENERATOR_URI="https://crd-test.davinci.hl7.org" \ No newline at end of file diff --git a/.env.development b/.env.development new file mode 100644 index 0000000..0312f9c --- /dev/null +++ b/.env.development @@ -0,0 +1,3 @@ +FHIR_RESOURCE_VALIDATOR_URL=http://localhost/hl7validatorapi +INFERNO_HOST=http://localhost:4567 +REDIS_URL=redis://localhost:6379/0 diff --git a/.env.production b/.env.production new file mode 100644 index 0000000..77fbfeb --- /dev/null +++ b/.env.production @@ -0,0 +1,3 @@ +REDIS_URL=redis://redis:6379/0 +INFERNO_HOST=http://localhost +FHIR_RESOURCE_VALIDATOR_URL=http://hl7_validator_service:3500 diff --git a/.env.test b/.env.test new file mode 100644 index 0000000..2d961cd --- /dev/null +++ b/.env.test @@ -0,0 +1,2 @@ +CRD_FHIR_RESOURCE_VALIDATOR_URL=https://example.com/hl7validatorapi +ASYNC_JOBS=false diff --git a/.github/workflows/ruby.yml b/.github/workflows/ruby.yml new file mode 100644 index 0000000..bc979ea --- /dev/null +++ b/.github/workflows/ruby.yml @@ -0,0 +1,54 @@ +name: Ruby + +on: + push: + branches: [ main ] + pull_request: + branches: [ main ] + +jobs: + build: + runs-on: ubuntu-latest + strategy: + matrix: + ruby-version: ['3.1.2'] + + steps: + - uses: actions/checkout@v2 + - name: Set up Ruby + uses: ruby/setup-ruby@v1 + with: + ruby-version: ${{ matrix.ruby-version }} + bundler-cache: true # runs 'bundle install' and caches installed gems automatically + + test: + needs: build + runs-on: ubuntu-latest + strategy: + matrix: + ruby-version: ['3.1.2'] + steps: + - uses: actions/checkout@v2 + - name: Set up Ruby + uses: ruby/setup-ruby@v1 + with: + ruby-version: ${{ matrix.ruby-version }} + bundler-cache: true # runs 'bundle install' and caches installed gems automatically + - name: Run tests + run: bundle exec rake + + lint: + needs: build + runs-on: ubuntu-latest + strategy: + matrix: + ruby-version: ['3.1.2'] + steps: + - uses: actions/checkout@v3 + - name: Set up Ruby + uses: ruby/setup-ruby@v1 + with: + ruby-version: ${{ matrix.ruby-version }} + bundler-cache: true # runs 'bundle install' and caches installed gems automatically + - name: Rubocop + run: bundle exec rubocop diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..ee52933 --- /dev/null +++ b/.gitignore @@ -0,0 +1,20 @@ +/data/*.db +/data/redis/**/*.rdb +/data/redis/**/*.aof +/data/redis/**/*.manifest +/tmp +.env.local +.env.*.local +**/.DS_Store +.vscode/* +.project +.settings/.jsdtscope +.settings/org.eclipse.wst.jsdt.ui.superType.container +.settings/org.eclipse.wst.jsdt.ui.superType.name +.idea + +/coverage +/spec/examples.txt +/docs/yard +.yardoc +node_modules diff --git a/.rspec b/.rspec new file mode 100644 index 0000000..c99d2e7 --- /dev/null +++ b/.rspec @@ -0,0 +1 @@ +--require spec_helper diff --git a/.rubocop.yml b/.rubocop.yml new file mode 100644 index 0000000..e56137b --- /dev/null +++ b/.rubocop.yml @@ -0,0 +1,87 @@ +require: + - rubocop-rake + - rubocop-rspec + +AllCops: + NewCops: enable + SuggestExtensions: false + TargetRubyVersion: 3.1 + Exclude: + - 'Gemfile' + - 'vendor/**/*' + +Layout/LineLength: + Max: 120 + +Layout/MultilineMethodCallIndentation: + EnforcedStyle: 'indented' + +Style/Documentation: + Enabled: false + +Style/FrozenStringLiteralComment: + Enabled: false + +Style/NumericLiterals: + Enabled: false + +Style/OpenStructUse: + Exclude: + - 'spec/**/*' + +Style/SymbolArray: + Enabled: false + +Style/WordArray: + Enabled: false + +# 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 + +RSpec/AnyInstance: + Enabled: false + +RSpec/ExampleLength: + Enabled: false + +RSpec/FilePath: + CustomTransform: + DaVinciCRDTestKit: davinci_crd_test_kit + +RSpec/MultipleExpectations: + Enabled: false + +RSpec/MultipleMemoizedHelpers: + Enabled: false + +RSpec/NestedGroups: + Enabled: false + +RSpec/NotToNot: + EnforcedStyle: to_not + +RSpec/SpecFilePathFormat: + CustomTransform: + DaVinciCRDTestKit: davinci_crd_test_kit + +Gemspec/RequireMFA: + Enabled: false diff --git a/.ruby-version b/.ruby-version new file mode 100644 index 0000000..6ebad14 --- /dev/null +++ b/.ruby-version @@ -0,0 +1 @@ +3.1.2 \ No newline at end of file diff --git a/.tool-versions b/.tool-versions new file mode 100644 index 0000000..b8ab7ea --- /dev/null +++ b/.tool-versions @@ -0,0 +1 @@ +ruby 3.1.2 \ No newline at end of file diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..f54c9c0 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,24 @@ +FROM ruby:3.1.2 + +ENV INSTALL_PATH=/opt/inferno/ +ENV APP_ENV=production +RUN mkdir -p $INSTALL_PATH + +WORKDIR $INSTALL_PATH + +ADD *.gemspec $INSTALL_PATH +ADD Gemfile* $INSTALL_PATH +ADD lib/davinci_crd_test_kit/version.rb $INSTALL_PATH/lib/davinci_crd_test_kit/version.rb +RUN gem update --system +RUN gem install bundler +# The below RUN line is commented out for development purposes, because any change to the +# required gems will break the dockerfile build process. +# If you want to run in Deploy mode, just run `bundle install` locally to update +# Gemfile.lock, and uncomment the following line. +# RUN bundle config set --local deployment 'true' +RUN bundle install + +ADD . $INSTALL_PATH + +EXPOSE 4567 +CMD ["bundle", "exec", "puma"] diff --git a/Gemfile b/Gemfile new file mode 100644 index 0000000..c26f750 --- /dev/null +++ b/Gemfile @@ -0,0 +1,21 @@ +# frozen_string_literal: true + +source "https://rubygems.org" + +gemspec + +group :development, :test do + gem 'debug' + + gem "rubocop", "~> 1.56" + gem 'rubocop-rspec', require: false + gem "rubocop-rake", "~> 0.6.0" +end + +group :test do + gem 'database_cleaner-sequel', '~> 1.8' + gem 'factory_bot', '~> 6.1' + gem 'rack-test' + gem 'rspec', '~> 3.10' + gem 'webmock', '~> 3.11' +end diff --git a/Gemfile.lock b/Gemfile.lock new file mode 100644 index 0000000..55d6930 --- /dev/null +++ b/Gemfile.lock @@ -0,0 +1,364 @@ +PATH + remote: . + specs: + davinci_crd_test_kit (0.9.0) + inferno_core (~> 0.4.37) + smart_app_launch_test_kit (~> 0.4.1) + tls_test_kit (~> 0.2.1) + +GEM + remote: https://rubygems.org/ + specs: + activesupport (6.1.7.8) + concurrent-ruby (~> 1.0, >= 1.0.2) + i18n (>= 1.6, < 2) + minitest (>= 5.1) + tzinfo (~> 2.0) + zeitwerk (~> 2.3) + addressable (2.8.6) + public_suffix (>= 2.0.2, < 6.0) + aes_key_wrap (1.1.0) + ast (2.4.2) + base62-rb (0.3.1) + base64 (0.2.0) + bcp47 (0.3.3) + i18n + bigdecimal (3.1.8) + bindata (2.5.0) + blueprinter (0.25.2) + byebug (11.1.3) + coderay (1.1.3) + concurrent-ruby (1.3.1) + connection_pool (2.4.1) + crack (1.0.0) + bigdecimal + rexml + database_cleaner (1.99.0) + database_cleaner-sequel (1.99.0) + database_cleaner (~> 1.99.0) + sequel + date_time_precision (0.8.1) + debug (1.9.2) + irb (~> 1.10) + reline (>= 0.3.8) + diff-lcs (1.5.1) + domain_name (0.6.20240107) + dotenv (2.8.1) + dry-auto_inject (1.0.1) + dry-core (~> 1.0) + zeitwerk (~> 2.6) + dry-configurable (1.0.0) + dry-core (~> 1.0, < 2) + zeitwerk (~> 2.6) + dry-container (0.10.0) + concurrent-ruby (~> 1.0) + dry-core (1.0.0) + concurrent-ruby (~> 1.0) + zeitwerk (~> 2.6) + dry-inflector (1.0.0) + dry-system (1.0.0) + dry-auto_inject (~> 1.0.0.rc1, < 2) + dry-configurable (~> 1.0, < 2) + dry-core (~> 1.0, < 2) + dry-inflector (~> 1.0, < 2) + dry-transformer (1.0.1) + zeitwerk (~> 2.6) + factory_bot (6.4.6) + activesupport (>= 5.0.0) + faraday (1.10.3) + faraday-em_http (~> 1.0) + faraday-em_synchrony (~> 1.0) + faraday-excon (~> 1.1) + faraday-httpclient (~> 1.0) + faraday-multipart (~> 1.0) + faraday-net_http (~> 1.0) + faraday-net_http_persistent (~> 1.0) + faraday-patron (~> 1.0) + faraday-rack (~> 1.0) + faraday-retry (~> 1.0) + ruby2_keywords (>= 0.0.4) + faraday-em_http (1.0.0) + faraday-em_synchrony (1.0.0) + faraday-excon (1.1.0) + faraday-httpclient (1.0.1) + faraday-multipart (1.0.4) + multipart-post (~> 2) + faraday-net_http (1.0.1) + faraday-net_http_persistent (1.2.0) + faraday-patron (1.0.0) + faraday-rack (1.0.0) + faraday-retry (1.0.3) + faraday_middleware (1.2.0) + faraday (~> 1.0) + fhir_client (5.0.3) + activesupport (>= 3) + addressable (>= 2.3) + fhir_dstu2_models (>= 1.1.1) + fhir_models (>= 4.2.1) + fhir_stu3_models (>= 3.1.1) + nokogiri (>= 1.10.4) + oauth2 (~> 1.1) + rack (>= 1.5) + rest-client (~> 2.0) + tilt (>= 1.1) + fhir_dstu2_models (1.2.0) + bcp47 (>= 0.3) + date_time_precision (>= 0.8) + mime-types (>= 3.0) + nokogiri (>= 1.11.4) + fhir_models (4.3.0) + bcp47 (>= 0.3) + date_time_precision (>= 0.8) + mime-types (>= 3.0) + nokogiri (>= 1.11.4) + fhir_stu3_models (3.2.0) + bcp47 (>= 0.3) + date_time_precision (>= 0.8) + mime-types (>= 3.0) + nokogiri (>= 1.11.4) + hanami-controller (2.0.0) + dry-configurable (~> 1.0, < 2) + dry-core (~> 1.0) + hanami-utils (~> 2.0) + rack (~> 2.0) + zeitwerk (~> 2.6) + hanami-router (2.0.0) + mustermann (~> 1.0) + mustermann-contrib (~> 1.0) + rack (~> 2.0) + hanami-utils (2.1.0) + concurrent-ruby (~> 1.0) + dry-core (~> 1.0, < 2) + dry-transformer (~> 1.0, < 2) + hansi (0.2.1) + hashdiff (1.1.0) + http-accept (1.7.0) + http-cookie (1.0.6) + domain_name (~> 0.5) + httpclient (2.8.3) + i18n (1.14.5) + concurrent-ruby (~> 1.0) + inferno_core (0.4.38) + activesupport (~> 6.1.7.5) + base62-rb (= 0.3.1) + blueprinter (= 0.25.2) + dotenv (~> 2.7) + dry-configurable (= 1.0.0) + dry-container (= 0.10.0) + dry-core (= 1.0.0) + dry-inflector (= 1.0.0) + dry-system (= 1.0.0) + faraday (~> 1.2) + faraday_middleware (~> 1.2) + fhir_client (>= 5.0.3) + fhir_models (>= 4.2.2) + hanami-controller (= 2.0.0) + hanami-router (= 2.0.0) + oj (= 3.11.0) + pry + pry-byebug + puma (~> 5.6.7) + rake (~> 13.0) + sequel (~> 5.42.0) + sidekiq (~> 7.2.4) + sqlite3 (~> 1.4) + thor (~> 1.2.1) + tty-markdown (~> 0.7.1) + io-console (0.7.2) + irb (1.12.0) + rdoc + reline (>= 0.4.2) + json (2.7.1) + json-jwt (1.15.3.1) + activesupport (>= 4.2) + aes_key_wrap + bindata + httpclient + jwt (2.8.1) + base64 + kramdown (2.4.0) + rexml + language_server-protocol (3.17.0.3) + method_source (1.1.0) + mime-types (3.5.2) + mime-types-data (~> 3.2015) + mime-types-data (3.2024.0604) + minitest (5.23.1) + multi_json (1.15.0) + multi_xml (0.7.1) + bigdecimal (~> 3.1) + multipart-post (2.4.1) + mustermann (1.1.2) + ruby2_keywords (~> 0.0.1) + mustermann-contrib (1.1.2) + hansi (~> 0.2.0) + mustermann (= 1.1.2) + netrc (0.11.0) + nio4r (2.7.3) + nokogiri (1.16.5-aarch64-linux) + racc (~> 1.4) + nokogiri (1.16.5-arm-linux) + racc (~> 1.4) + nokogiri (1.16.5-arm64-darwin) + racc (~> 1.4) + nokogiri (1.16.5-x86-linux) + racc (~> 1.4) + nokogiri (1.16.5-x86_64-darwin) + racc (~> 1.4) + nokogiri (1.16.5-x86_64-linux) + racc (~> 1.4) + oauth2 (1.4.11) + faraday (>= 0.17.3, < 3.0) + jwt (>= 1.0, < 3.0) + multi_json (~> 1.3) + multi_xml (~> 0.5) + rack (>= 1.2, < 4) + oj (3.11.0) + parallel (1.24.0) + parser (3.3.0.5) + ast (~> 2.4.1) + racc + pastel (0.8.0) + tty-color (~> 0.5) + pry (0.14.2) + coderay (~> 1.1) + method_source (~> 1.0) + pry-byebug (3.10.1) + byebug (~> 11.0) + pry (>= 0.13, < 0.15) + psych (5.1.2) + stringio + public_suffix (5.0.5) + puma (5.6.8) + nio4r (~> 2.0) + racc (1.8.0) + rack (2.2.9) + rack-test (2.1.0) + rack (>= 1.3) + rainbow (3.1.1) + rake (13.2.1) + rdoc (6.6.3.1) + psych (>= 4.0.0) + redis-client (0.22.2) + connection_pool + regexp_parser (2.9.0) + reline (0.5.0) + io-console (~> 0.5) + rest-client (2.1.0) + http-accept (>= 1.7.0, < 2.0) + http-cookie (>= 1.0.2, < 2.0) + mime-types (>= 1.16, < 4.0) + netrc (~> 0.8) + rexml (3.2.8) + strscan (>= 3.0.9) + rouge (4.2.1) + rspec (3.13.0) + rspec-core (~> 3.13.0) + rspec-expectations (~> 3.13.0) + rspec-mocks (~> 3.13.0) + rspec-core (3.13.0) + rspec-support (~> 3.13.0) + rspec-expectations (3.13.0) + diff-lcs (>= 1.2.0, < 2.0) + rspec-support (~> 3.13.0) + rspec-mocks (3.13.0) + diff-lcs (>= 1.2.0, < 2.0) + rspec-support (~> 3.13.0) + rspec-support (3.13.1) + rubocop (1.62.1) + json (~> 2.3) + language_server-protocol (>= 3.17.0) + parallel (~> 1.10) + parser (>= 3.3.0.2) + rainbow (>= 2.2.2, < 4.0) + regexp_parser (>= 1.8, < 3.0) + rexml (>= 3.2.5, < 4.0) + rubocop-ast (>= 1.31.1, < 2.0) + ruby-progressbar (~> 1.7) + unicode-display_width (>= 2.4.0, < 3.0) + rubocop-ast (1.31.2) + parser (>= 3.3.0.4) + rubocop-capybara (2.20.0) + rubocop (~> 1.41) + rubocop-factory_bot (2.25.1) + rubocop (~> 1.41) + rubocop-rake (0.6.0) + rubocop (~> 1.0) + rubocop-rspec (2.28.0) + rubocop (~> 1.40) + rubocop-capybara (~> 2.17) + rubocop-factory_bot (~> 2.22) + rubocop-rspec_rails (~> 2.28) + rubocop-rspec_rails (2.28.2) + rubocop (~> 1.40) + ruby-progressbar (1.13.0) + ruby2_keywords (0.0.5) + sequel (5.42.0) + sidekiq (7.2.4) + concurrent-ruby (< 2) + connection_pool (>= 2.3.0) + rack (>= 2.2.4) + redis-client (>= 0.19.0) + smart_app_launch_test_kit (0.4.2) + inferno_core (>= 0.4.2) + json-jwt (~> 1.15.3) + jwt (~> 2.6) + tls_test_kit (~> 0.2.0) + sqlite3 (1.7.3-aarch64-linux) + sqlite3 (1.7.3-arm-linux) + sqlite3 (1.7.3-arm64-darwin) + sqlite3 (1.7.3-x86-linux) + sqlite3 (1.7.3-x86_64-darwin) + sqlite3 (1.7.3-x86_64-linux) + stringio (3.1.0) + strings (0.2.1) + strings-ansi (~> 0.2) + unicode-display_width (>= 1.5, < 3.0) + unicode_utils (~> 1.4) + strings-ansi (0.2.0) + strscan (3.1.0) + thor (1.2.2) + tilt (2.3.0) + tls_test_kit (0.2.1) + inferno_core (>= 0.4.2) + tty-color (0.6.0) + tty-markdown (0.7.2) + kramdown (>= 1.16.2, < 3.0) + pastel (~> 0.8) + rouge (>= 3.14, < 5.0) + strings (~> 0.2.0) + tty-color (~> 0.5) + tty-screen (~> 0.8) + tty-screen (0.8.2) + tzinfo (2.0.6) + concurrent-ruby (~> 1.0) + unicode-display_width (2.5.0) + unicode_utils (1.4.0) + webmock (3.23.0) + addressable (>= 2.8.0) + crack (>= 0.3.2) + hashdiff (>= 0.4.0, < 2.0.0) + zeitwerk (2.6.15) + +PLATFORMS + aarch64-linux + arm-linux + arm64-darwin + x86-linux + x86_64-darwin + x86_64-linux + +DEPENDENCIES + database_cleaner-sequel (~> 1.8) + davinci_crd_test_kit! + debug + factory_bot (~> 6.1) + rack-test + rspec (~> 3.10) + rubocop (~> 1.56) + rubocop-rake (~> 0.6.0) + rubocop-rspec + webmock (~> 3.11) + +BUNDLED WITH + 2.5.3 diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..8dada3e --- /dev/null +++ b/LICENSE @@ -0,0 +1,201 @@ + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "{}" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright {yyyy} {name of copyright owner} + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/NOTICE.md b/NOTICE.md new file mode 100644 index 0000000..1c4028c --- /dev/null +++ b/NOTICE.md @@ -0,0 +1,11 @@ +#

NOTICE

+ +This software was produced for the U. S. Government under Contract Number 75FCMC18D0047, and is subject to Federal Acquisition Regulation Clause 52.227-14, Rights in Data-General. + +No other use other than that granted to the U. S. Government, or to those acting on behalf of the U. S. Government under that Clause is authorized without the express written permission of The MITRE Corporation. + +To the extent necessary MITRE hereby grants express written permission to use, reproduce, distribute, modify, and otherwise leverage this software to the extent permitted by the Apache 2.0 license. + +For further information, please contact The MITRE Corporation, Contracts Management Office, 7515 Colshire Drive, McLean, VA 22102-7539, (703) 983-6000. + +###

**ⓒ2024 The MITRE Corporation.**

diff --git a/Procfile b/Procfile new file mode 100644 index 0000000..4f784b9 --- /dev/null +++ b/Procfile @@ -0,0 +1,2 @@ +web: bundle exec puma +worker: bundle exec sidekiq -r ./worker.rb diff --git a/README.md b/README.md new file mode 100644 index 0000000..2c7802f --- /dev/null +++ b/README.md @@ -0,0 +1,133 @@ +# Da Vinci Coverage Requirements Discovery (CRD) Test Kit + +This is an [Inferno](https://github.com/inferno-community/inferno-core) test kit +for the [Da Vinci Coverage Requirements Discovery (CRD) FHIR Implementation +Guide v2.0.1](https://hl7.org/fhir/us/davinci-crd/STU2). + +It contains test suites to test the two actors defined by the CRD specification: +- [CRD + Client](https://hl7.org/fhir/us/davinci-crd/STU2/CapabilityStatement-crd-client.html): + responsible for initiating CDS Hooks calls and consuming received decision + support. It is also responsible for returning data requested by the CRD Server + needed to provide that decision support. +- [CRD + Server](https://hl7.org/fhir/us/davinci-crd/STU2/CapabilityStatement-crd-server.html): + responsible for responding to CDS Hooks calls and responding with appropriate + decision support. + +This test kit is [open +source](https://github.com/inferno-framework/davinci-crd-test-kit#LICENSE) and +freely available for use or adoption by the Health IT community including EHR +vendors, health app developers, and testing labs. + +## Status + +These tests are a **DRAFT** intended to allow CRD implementers to perform +preliminary checks of their implementations against the CRD IG requirements and +provide feedback on the tests. Future versions of these tests may validate other +requirements and may change how these are tested. + +Additional details on the IG requirements that underlie this test kit can be +found in this [CRD testing note](docs/crd-testing-notes.md). The document +includes the requirements extracted from the IG and specifies the ones that are +not testable. + +## Test Scope and Limitations + +Documentation of the current tests and their limitations can be found in each +suite's (client and server) description when the tests are run. + +### Test Scope + +At a high-level, the tests check: + +- **Client Suite**: + - The ability of a CRD client to initiate CDS Hooks calls. + - The ability of a CRD client to support the FHIR interactions defined in the + implementation guide. +- **Server Suite**: + - The ability of a CRD server to return a valid response when invoking its + discovery endpoint. + - The ability of a CRD server to return a valid response when invoking a + supported hook, including producing the required response types across all + hooks invoked. + +### Limitations + +- **Client Suite**: + - This suite does not implement any payer business logic, so the responses to + hook calls are simple hard-coded responses. + - The tests cannot verify that a client is able to consume the received + decision support. Testers should consider this requirement to be verified + through attestation and should not represent their systems as having passed + these tests if this requirement is not met. + - Hook configuration is not tested. +- **Server Suite**: + - Inferno is unable to determine what requests will result in specific kinds + of responses from the server under test (e.g., what will result in + Instructions being returned vs. Coverage Information). As a result, the + tester must supply the request bodies that will cause the system under test + to return the desired response types. + - The ability of a CRD server to request additional FHIR resources is not + tested. + - Hook configuration is not tested. + +## How to Run + +Use either of the following methods to run the suites within this test kit. If +you would like to try out the tests but don’t have a CRD implementation, the +test home pages include instructions for trying out the tests, including + +- For server testing: a [public CRD server reference + implementation](https://crd.davinci.hl7.org/) ([code on + github](https://github.com/HL7-DaVinci/CRD?tab=readme-ov-file)) +- For client testing: a [public CRD client reference + implementation](https://crd-request-generator.davinci.hl7.org/) ([code on + github](https://github.com/HL7-DaVinci/CRD?tab=readme-ov-file)) + +Detailed instructions can be found in the suite descriptions when the tests are +run. + +### ONC Hosted Instance + +You can run the CRD test kit via the [ONC +Inferno](https://inferno.healthit.gov/test-kits/davinci-crd/) website by +choosing the “Da Vinci Coverage Requirements Discovery (CRD) Test Kit”. + +### Local Inferno Instance + +- Download the source code from this repository. +- Open a terminal in the directory containing the downloaded code. +- In the terminal, run `setup.sh`. +- In the terminal, run `run.sh`. +- Use a web browser to navigate to `http://localhost`. + +## Providing Feedback and Reporting Issues + +We welcome feedback on the tests, including but not limited to the following areas: +- Validation logic, such as potential bugs, lax checks, and unexpected failures. +- Requirements coverage, such as requirements that have been missed and tests + that necessitate features that the IG does not require. +- User experience, such as confusing or missing information in the test UI. + +Please report any problems with these tests in Github Issues. The team may also +be reached in the [#inferno Zulip +stream](https://chat.fhir.org/#narrow/stream/179309-inferno). + +## License + +Licensed under the Apache License, Version 2.0 (the "License"); you may not use +this file except in compliance with the License. You may obtain a copy of the +License at +``` +http://www.apache.org/licenses/LICENSE-2.0 +``` +Unless required by applicable law or agreed to in writing, software distributed +under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR +CONDITIONS OF ANY KIND, either express or implied. See the License for the +specific language governing permissions and limitations under the License. + +## Trademark Notice + +HL7, FHIR and the FHIR [FLAME DESIGN] are the registered trademarks of Health +Level Seven International and their use does not constitute endorsement by HL7. diff --git a/Rakefile b/Rakefile new file mode 100644 index 0000000..f0f0446 --- /dev/null +++ b/Rakefile @@ -0,0 +1,15 @@ +begin + require 'rspec/core/rake_task' + RSpec::Core::RakeTask.new(:spec) + task default: :spec +rescue LoadError # rubocop:disable Lint/SuppressedException +end + +namespace :db do + desc 'Apply changes to the database' + task :migrate do + require 'inferno/config/application' + require 'inferno/utils/migration' + Inferno::Utils::Migration.new.run + end +end diff --git a/config.ru b/config.ru new file mode 100644 index 0000000..5d5e798 --- /dev/null +++ b/config.ru @@ -0,0 +1,11 @@ +require 'inferno' + +use Rack::Static, + urls: Inferno::Utils::StaticAssets.static_assets_map, + root: Inferno::Utils::StaticAssets.inferno_path + +Inferno::Application.finalize! + +use Inferno::Utils::Middleware::RequestLogger + +run Inferno::Web.app diff --git a/config/database.yml b/config/database.yml new file mode 100644 index 0000000..5ddc37f --- /dev/null +++ b/config/database.yml @@ -0,0 +1,18 @@ +development: + adapter: sqlite + database: data/inferno_development.db + max_connections: 10 + # user: + # password: + # host: + # port: + +production: + adapter: sqlite + database: data/inferno_production.db + max_connections: 10 + +test: + adapter: sqlite + database: ':memory:' + max_connections: 10 diff --git a/config/nginx.background.conf b/config/nginx.background.conf new file mode 100644 index 0000000..eb4f52b --- /dev/null +++ b/config/nginx.background.conf @@ -0,0 +1,87 @@ +# this sets the user nginx will run as, +#and the number of worker processes +user nobody nogroup; +worker_processes 2; +#user www-data; +#worker_processes auto; + +# setup where nginx will log errors to +# and where the nginx process id resides +error_log /var/log/nginx/error.log; +pid /var/run/nginx.pid; + +events { + worker_connections 1024; + # set to on if you have more than 1 worker_processes + accept_mutex on; +} + +http { + include /etc/nginx/mime.types; + + default_type application/octet-stream; + access_log /tmp/nginx.access.log combined; + + # use the kernel sendfile + # sendfile on; # this causes over-caching because modified timestamps lost in VM + # prepend http headers before sendfile() + tcp_nopush on; + + keepalive_timeout 600; + tcp_nodelay on; + + gzip on; + gzip_vary on; + gzip_min_length 500; + + gzip_disable "MSIE [1-6]\.(?!.*SV1)"; + gzip_types text/plain text/xml text/css + text/comma-separated-values + text/javascript application/x-javascript + application/atom+xml image/x-icon; + + # configure the virtual host + server { + # replace with your domain name + # server_name inferno-server; + + # port to listen for requests on + listen 80; + + # maximum accepted body size of client request + client_max_body_size 4G; + # the server will close connections after this time + keepalive_timeout 600; + + location /validator { + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header Host $http_host; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_set_header X-Forwarded-Port $server_port; + proxy_redirect off; + proxy_set_header Connection ''; + proxy_http_version 1.1; + chunked_transfer_encoding off; + proxy_buffering off; + proxy_cache off; + + proxy_pass http://fhir_validator_app; + } + + location /hl7validatorapi/ { + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header Host $http_host; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_set_header X-Forwarded-Port $server_port; + proxy_redirect off; + proxy_set_header Connection ''; + proxy_http_version 1.1; + chunked_transfer_encoding off; + proxy_buffering off; + proxy_cache off; + proxy_read_timeout 600s; + + proxy_pass http://hl7_validator_service:3500/; + } + } +} diff --git a/config/nginx.conf b/config/nginx.conf new file mode 100644 index 0000000..a7c00ae --- /dev/null +++ b/config/nginx.conf @@ -0,0 +1,102 @@ +# this sets the user nginx will run as, +#and the number of worker processes +user nobody nogroup; +worker_processes 2; +#user www-data; +#worker_processes auto; + +# setup where nginx will log errors to +# and where the nginx process id resides +error_log /var/log/nginx/error.log; +pid /var/run/nginx.pid; + +events { + worker_connections 1024; + # set to on if you have more than 1 worker_processes + accept_mutex on; +} + +http { + include /etc/nginx/mime.types; + + default_type application/octet-stream; + access_log /tmp/nginx.access.log combined; + + # use the kernel sendfile + # sendfile on; # this causes over-caching because modified timestamps lost in VM + # prepend http headers before sendfile() + tcp_nopush on; + + keepalive_timeout 600; + tcp_nodelay on; + + gzip on; + gzip_vary on; + gzip_min_length 500; + + gzip_disable "MSIE [1-6]\.(?!.*SV1)"; + gzip_types text/plain text/xml text/css + text/comma-separated-values + text/javascript application/x-javascript + application/atom+xml image/x-icon; + + # configure the virtual host + server { + # replace with your domain name + # server_name inferno-server; + + # port to listen for requests on + listen 80; + + # maximum accepted body size of client request + client_max_body_size 4G; + # the server will close connections after this time + keepalive_timeout 600; + + location / { + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header Host $http_host; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_set_header X-Forwarded-Port $server_port; + proxy_redirect off; + proxy_set_header Connection ''; + proxy_http_version 1.1; + chunked_transfer_encoding off; + proxy_buffering off; + proxy_cache off; + + proxy_pass http://inferno:4567; + } + + location /validator { + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header Host $http_host; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_set_header X-Forwarded-Port $server_port; + proxy_redirect off; + proxy_set_header Connection ''; + proxy_http_version 1.1; + chunked_transfer_encoding off; + proxy_buffering off; + proxy_cache off; + + proxy_pass http://fhir_validator_app; + } + + location /hl7validatorapi/ { + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header Host $http_host; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_set_header X-Forwarded-Port $server_port; + proxy_redirect off; + proxy_set_header Connection ''; + proxy_http_version 1.1; + chunked_transfer_encoding off; + proxy_buffering off; + proxy_cache off; + proxy_read_timeout 600s; + + proxy_pass http://hl7_validator_service:3500/; + } + } +} diff --git a/config/presets/inferno_crd_client_suite.json.erb b/config/presets/inferno_crd_client_suite.json.erb new file mode 100644 index 0000000..74c1af3 --- /dev/null +++ b/config/presets/inferno_crd_client_suite.json.erb @@ -0,0 +1,102 @@ +{ + "title": "Inferno CRD Client Suite", + "id": "inferno-crd-client-suite", + "test_suite_id": "crd_server", + "inputs": [ + { + "name": "base_url", + "title": "CRD server base URL", + "type": "text", + "value": "<%= Inferno::Application['base_url'] %>/custom/crd_client" + }, + { + "name": "authentication_required", + "default": "no", + "options": { + "list_options": [ + { + "label": "No", + "value": "no" + }, + { + "label": "Yes", + "value": "yes" + } + ] + }, + "title": "Discovery endpoint requires authentication?", + "type": "radio", + "value": "no" + }, + { + "name": "encryption_method", + "default": "ES384", + "description": "CDS Hooks recommends ES384 and RS384 for JWT signature verification. Select which method to use.", + "options": { + "list_options": [ + { + "label": "ES384", + "value": "ES384" + }, + { + "label": "RS384", + "value": "RS384" + } + ] + }, + "title": "JWT Signing Algorithm", + "type": "radio", + "value": "ES384" + }, + { + "name": "jwks_kid", + "description": "The key ID of the JWKS private key to use for signing the JWTs when invoking a CDS service endpoint requiring authentication. Defaults to the first JWK in the list if no kid is supplied.", + "optional": true, + "title": "CDS Services JWKS kid", + "type": "text", + "value": "" + }, + { + "name": "appointment_book_request_bodies", + "description": "Provide a list of request bodies send multiple requests. e.g. [json_body_1, json_body_2]", + "title": "Request bodies collection to use to invoke the `appointment-book` hook", + "type": "textarea", + "value": "[\n {\n \"hookInstance\": \"d1577c69-dfbe-44ad-ba6d-3e05e953b2ea\",\n \"fhirServer\": \"https://inferno-qa.healthit.gov/reference-server/r4\",\n \"hook\": \"appointment-book\",\n \"fhirAuthorization\": {\n \"access_token\": \"SAMPLE_TOKEN\",\n \"token_type\": \"Bearer\",\n \"expires_in\": 300,\n \"scope\": \"user/Patient.read user/Observation.read\",\n \"subject\": \"cds-service\"\n },\n \"context\": {\n \"userId\": \"Practitioner/pra1255\",\n \"patientId\": \"pat014\",\n \"encounterId\": \"enc-pat014-cold\",\n \"appointments\": {\n \"resourceType\": \"Bundle\",\n \"id\": \"2a020b2f-1577-4ccb-8ee8-1dc09060f727\",\n \"meta\": {\n \"lastUpdated\": \"2024-05-10T03:55:33.823+00:00\"\n },\n \"type\": \"searchset\",\n \"total\": 2,\n \"link\": [\n {\n \"relation\": \"self\",\n \"url\": \"https://inferno-qa.healthit.gov/reference-server/r4/Appointment?patient=pat014\"\n }\n ],\n \"entry\": [\n {\n \"fullUrl\": \"https://inferno-qa.healthit.gov/reference-server/r4/Appointment/125\",\n \"resource\": {\n \"resourceType\": \"Appointment\",\n \"id\": \"125\",\n \"meta\": {\n \"versionId\": \"1\",\n \"lastUpdated\": \"2024-05-10T03:52:09.983+00:00\",\n \"source\": \"#KIaKP1Pfw7EVpLjl\"\n },\n \"status\": \"proposed\",\n \"serviceType\": [\n {\n \"coding\": [\n {\n \"system\": \"http://terminology.hl7.org/CodeSystem/service-type\",\n \"code\": \"183\",\n \"display\": \"Sleep Medicine\"\n }\n ]\n }\n ],\n \"appointmentType\": {\n \"coding\": [\n {\n \"system\": \"http://terminology.hl7.org/CodeSystem/v2-0276\",\n \"code\": \"FOLLOWUP\",\n \"display\": \"A follow up visit from a previous appointment\"\n }\n ]\n },\n \"description\": \"CPAP adjustments\",\n \"start\": \"2019-08-10T09:00:00Z\",\n \"end\": \"2019-08-10T11:00:00Z\",\n \"created\": \"2019-08-01\",\n \"participant\": [\n {\n \"actor\": {\n \"reference\": \"Patient/pat014\",\n \"display\": \"Peter James Chalmers\"\n },\n \"required\": \"required\",\n \"status\": \"tentative\"\n },\n {\n \"actor\": {\n \"reference\": \"Practitioner/pra1255\",\n \"display\": \"Dr Adam Careful\"\n },\n \"required\": \"required\",\n \"status\": \"accepted\"\n }\n ],\n \"requestedPeriod\": [\n {\n \"start\": \"2020-05-23\",\n \"end\": \"2020-05-23\"\n }\n ]\n },\n \"search\": {\n \"mode\": \"match\"\n }\n },\n {\n \"fullUrl\": \"https://inferno-qa.healthit.gov/reference-server/r4/Appointment/126\",\n \"resource\": {\n \"resourceType\": \"Appointment\",\n \"id\": \"126\",\n \"meta\": {\n \"versionId\": \"1\",\n \"lastUpdated\": \"2024-05-10T03:55:17.405+00:00\",\n \"source\": \"#RTJWuip7OTKyTjG7\"\n },\n \"status\": \"proposed\",\n \"appointmentType\": {\n \"coding\": [\n {\n \"system\": \"http://terminology.hl7.org/CodeSystem/v2-0276\",\n \"code\": \"CHECKUP\",\n \"display\": \"A routine check-up, such as an annual physical\"\n }\n ]\n },\n \"description\": \"Regular physical\",\n \"start\": \"2020-08-01T11:00:00Z\",\n \"end\": \"2020-08-01T13:00:00Z\",\n \"created\": \"2019-08-01\",\n \"participant\": [\n {\n \"actor\": {\n \"reference\": \"Patient/pat014\",\n \"display\": \"Peter James Chalmers\"\n },\n \"required\": \"required\",\n \"status\": \"tentative\"\n },\n {\n \"actor\": {\n \"reference\": \"Practitioner/pra1255\",\n \"display\": \"Dr Adam Careful\"\n },\n \"required\": \"required\",\n \"status\": \"accepted\"\n }\n ],\n \"requestedPeriod\": [\n {\n \"start\": \"2021-05-23\",\n \"end\": \"2021-05-23\"\n }\n ]\n },\n \"search\": {\n \"mode\": \"match\"\n }\n }\n ]\n }\n }\n }\n]\n" + }, + { + "name": "encounter_start_request_bodies", + "description": "Provide a list of request bodies send multiple requests. e.g. [json_body_1, json_body_2]", + "title": "Request bodies collection to use to invoke the `encounter-start` hook", + "type": "textarea", + "value": "[\n {\n \"hookInstance\": \"f3945c69-dfbe-44vf-ba6d-3e05e953b2ea\",\n \"fhirServer\": \"https://inferno-qa.healthit.gov/reference-server/r4\",\n \"hook\": \"encounter-start\",\n \"fhirAuthorization\": {\n \"access_token\": \"SAMPLE_TOKEN\",\n \"token_type\": \"Bearer\",\n \"expires_in\": 300,\n \"scope\": \"user/Patient.read user/Practitioner.read user/Encounter.read\",\n \"subject\": \"cds-service\"\n },\n \"context\": {\n \"userId\": \"Practitioner/pra1255\",\n \"patientId\": \"pat015\",\n \"encounterId\": \"127\"\n }\n }\n]\n" + }, + { + "name": "encounter_discharge_request_bodies", + "description": "Provide a list of request bodies send multiple requests. e.g. [json_body_1, json_body_2]", + "title": "Request bodies collection to use to invoke the `encounter-discharge` hook", + "type": "textarea", + "value": "[\n {\n \"hookInstance\": \"f3945c69-dfbe-44vf-ba6d-3e05e123b2ea\",\n \"fhirServer\": \"https://inferno-qa.healthit.gov/reference-server/r4\",\n \"hook\": \"encounter-discharge\",\n \"fhirAuthorization\": {\n \"access_token\": \"SAMPLE_TOKEN\",\n \"token_type\": \"Bearer\",\n \"expires_in\": 300,\n \"scope\": \"user/Patient.read user/Practitioner.read user/Encounter.read\",\n \"subject\": \"cds-service\"\n },\n \"context\": {\n \"userId\": \"Practitioner/pra1255\",\n \"patientId\": \"pat014\",\n \"encounterId\": \"enc-pat014-cold\"\n }\n }\n]\n" + }, + { + "name": "order_select_request_bodies", + "description": "Provide a list of request bodies send multiple requests. e.g. [json_body_1, json_body_2]", + "title": "Request bodies collection to use to invoke the `order-select` hook", + "type": "textarea", + "value": "[\n {\n \"hookInstance\": \"d1577c69-dfbe-44ad-ba6d-3e05e953b2ea\",\n \"fhirServer\": \"https://inferno-qa.healthit.gov/reference-server/r4\",\n \"hook\": \"order-select\",\n \"fhirAuthorization\": {\n \"access_token\": \"SAMPLE_TOKEN\",\n \"token_type\": \"Bearer\",\n \"expires_in\": 300,\n \"scope\": \"patient/Patient.read patient/Observation.read\",\n \"subject\": \"cds-service4\"\n },\n \"context\": {\n \"userId\": \"Practitioner/pra1234\",\n \"patientId\": \"pat014\",\n \"encounterId\": \"enc-pat014-cold\",\n \"selections\": [\n \"MedicationRequest/pat014-mr-azathioprine\"\n ],\n \"draftOrders\": {\n \"resourceType\": \"Bundle\",\n \"entry\": [\n {\n \"fullUrl\": \"https://inferno-qa.healthit.gov/reference-server/r4/MedicationRequest/pat014-mr-azathioprine\",\n \"resource\": {\n \"resourceType\": \"MedicationRequest\",\n \"id\": \"pat014-mr-azathioprine\",\n \"meta\": {\n \"versionId\": \"1\",\n \"lastUpdated\": \"2024-04-25T17:48:34.896+00:00\",\n \"source\": \"#R0SZmSGTb4YcV7ig\"\n },\n \"identifier\": [\n {\n \"type\": {\n \"coding\": [\n {\n \"system\": \"http://terminology.hl7.org/CodeSystem/v2-0203\",\n \"code\": \"PLAC\"\n }\n ]\n },\n \"value\": \"25839b73-fcc9-4706-8c77-a806995b8109\"\n }\n ],\n \"status\": \"draft\",\n \"intent\": \"order\",\n \"medicationCodeableConcept\": {\n \"coding\": [\n {\n \"system\": \"http://www.nlm.nih.gov/research/umls/rxnorm\",\n \"code\": \"105611\",\n \"display\": \"azathioprine 50 MG Oral Tablet [Imuran]\"\n }\n ]\n },\n \"subject\": {\n \"reference\": \"Patient/pat014\",\n \"display\": \"Theodor Roosevelt\"\n },\n \"authoredOn\": \"2020-05-11\",\n \"requester\": {\n \"reference\": \"Practitioner/pra1234\",\n \"display\": \"Jane Doe\"\n },\n \"reasonCode\": [\n {\n \"coding\": [\n {\n \"system\": \"http://snomed.info/sct\",\n \"code\": \"52042003\",\n \"display\": \"Systemic lupus erythematosus glomerulonephritis syndrome, World Health Organization class V (disorder)\"\n }\n ]\n }\n ],\n \"insurance\": [\n {\n \"reference\": \"Coverage/cov014\"\n }\n ],\n \"dosageInstruction\": [\n {\n \"sequence\": 1,\n \"text\": \"50 mg PO daily for remission induction\",\n \"timing\": {\n \"repeat\": {\n \"frequency\": 1,\n \"period\": 1,\n \"periodUnit\": \"d\"\n }\n },\n \"route\": {\n \"coding\": [\n {\n \"system\": \"http://snomed.info/sct\",\n \"code\": \"26643006\",\n \"display\": \"Oral route (qualifier value)\"\n }\n ]\n },\n \"doseAndRate\": [\n {\n \"type\": {\n \"coding\": [\n {\n \"system\": \"http://terminology.hl7.org/CodeSystem/dose-rate-type\",\n \"code\": \"ordered\",\n \"display\": \"Ordered\"\n }\n ]\n },\n \"doseQuantity\": {\n \"value\": 50,\n \"unit\": \"mg\",\n \"system\": \"http://unitsofmeasure.org\",\n \"code\": \"mg\"\n }\n }\n ]\n }\n ],\n \"dispenseRequest\": {\n \"numberOfRepeatsAllowed\": 3,\n \"quantity\": {\n \"value\": 90,\n \"system\": \"http://terminology.hl7.org/CodeSystem/v3-orderableDrugForm\",\n \"code\": \"TAB\"\n }\n }\n },\n \"search\": {\n \"mode\": \"match\"\n }\n }\n ]\n }\n }\n }\n]\n" + }, + { + "name": "order_dispatch_request_bodies", + "description": "Provide a list of request bodies send multiple requests. e.g. [json_body_1, json_body_2]", + "title": "Request bodies collection to use to invoke the `order-dispatch` hook", + "type": "textarea", + "value": "[\n {\n \"hookInstance\": \"f3945c39-dfbe-44vf-ba6d-3e05e123b2va\",\n \"fhirServer\": \"https://inferno-qa.healthit.gov/reference-server/r4\",\n \"hook\": \"order-dispatch\",\n \"fhirAuthorization\": {\n \"access_token\": \"SAMPLE_TOKEN\",\n \"token_type\": \"Bearer\",\n \"expires_in\": 300,\n \"scope\": \"user/Patient.read user/Practitioner.read user/ServiceRequest.read\",\n \"subject\": \"cds-service\"\n },\n \"context\": {\n \"patientId\": \"pat015\",\n \"order\": \"DeviceRequest/devreq-015-e0250\",\n \"performer\": \"Practitioner/pra1255\"\n }\n }\n]\n" + }, + { + "name": "order_sign_request_bodies", + "description": "Provide a list of request bodies send multiple requests. e.g. [json_body_1, json_body_2]", + "title": "Request bodies collection to use to invoke the `order-sign` hook", + "type": "textarea", + "value": "[\n {\n \"hookInstance\": \"d1577c69-dfbe-44ad-ba6d-3e05e953b2ea\",\n \"fhirServer\": \"https://inferno-qa.healthit.gov/reference-server/r4\",\n \"hook\": \"order-sign\",\n \"fhirAuthorization\": {\n \"access_token\": \"SAMPLE_TOKEN\",\n \"token_type\": \"Bearer\",\n \"expires_in\": 300,\n \"scope\": \"patient/Patient.read patient/Observation.read\",\n \"subject\": \"cds-service4\"\n },\n \"context\": {\n \"userId\": \"Practitioner/pra1234\",\n \"patientId\": \"pat015\",\n \"encounterId\": \"enc-pat014\",\n \"draftOrders\": {\n \"resourceType\": \"Bundle\",\n \"entry\": [\n {\n \"resource\": {\n \"resourceType\": \"DeviceRequest\",\n \"id\": \"devreq-015-e0250\",\n \"meta\": {\n \"versionId\": \"1\",\n \"lastUpdated\": \"2024-05-08T09:47:16.992-04:00\",\n \"source\": \"#Odh5ejWjud85tvNJ\",\n \"profile\": [\n \"http://hl7.org/fhir/us/davinci-crd/R4/StructureDefinition/profile-devicerequest-r4\"\n ]\n },\n \"identifier\": [\n {\n \"type\": {\n \"coding\": [\n {\n \"system\": \"http://terminology.hl7.org/CodeSystem/v2-0203\",\n \"code\": \"PLAC\"\n }\n ]\n },\n \"value\": \"f105372f-bbef-442c-ad7a-708fee7f8c93\"\n }\n ],\n \"status\": \"draft\",\n \"intent\": \"original-order\",\n \"codeCodeableConcept\": {\n \"coding\": [\n {\n \"system\": \"https://bluebutton.cms.gov/resources/codesystem/hcpcs\",\n \"code\": \"E0250\",\n \"display\": \"Hospital bed fixed height with any type of side rails, mattress\"\n }\n ]\n },\n \"subject\": {\n \"reference\": \"Patient/pat015\"\n },\n \"authoredOn\": \"2023-01-01T00:00:00Z\",\n \"requester\": {\n \"reference\": \"Practitioner/pra-hfairchild\"\n },\n \"performer\": {\n \"reference\": \"Practitioner/pra1234\"\n },\n \"insurance\": [\n {\n \"reference\": \"Coverage/cov015\"\n }\n ]\n }\n }\n ]\n }\n }\n }\n]\n" + } + ] +} diff --git a/config/presets/inferno_crd_server_suite.json.erb b/config/presets/inferno_crd_server_suite.json.erb new file mode 100644 index 0000000..e8d7046 --- /dev/null +++ b/config/presets/inferno_crd_server_suite.json.erb @@ -0,0 +1,173 @@ +{ + "title": "Inferno CRD Server Suite", + "id": "inferno-crd-server-suite", + "test_suite_id": "crd_client", + "inputs": [ + { + "name": "iss", + "title": "URI of the issuer of the JWT used to authorize CDS Hooks calls", + "type": "text", + "value": "<%= Inferno::Application['base_url'] %>/custom/crd_server" + }, + { + "name": "url", + "description": "URL of the FHIR endpoint used by SMART applications", + "title": "FHIR Endpoint", + "type": "text", + "value": "<%= ENV.fetch('INFERNO_REFERENCE_SERVER_URI', 'https://inferno-qa.healthit.gov') %>/reference-server/r4" + }, + { + "name": "ehr_smart_credentials", + "optional": true, + "title": "OAuth Credentials", + "type": "oauth_credentials", + "value": { + "access_token": "SAMPLE_TOKEN", + "refresh_token": "", + "expires_in": "", + "client_id": "", + "client_secret": "", + "token_url": "" + } + }, + { + "name": "ehr_client_id", + "description": "Client ID provided during registration of Inferno as an EHR launch application", + "title": "EHR Launch Client ID", + "type": "text", + "value": "SAMPLE_PUBLIC_CLIENT_ID" + }, + { + "name": "ehr_requested_scopes", + "default": "launch openid fhirUser offline_access user/*.rs", + "description": "OAuth 2.0 scope provided by system to enable all required functionality", + "title": "EHR Launch Scope", + "type": "textarea", + "value": "launch openid fhirUser offline_access user/*.rs" + }, + { + "name": "patient_ids", + "description": "Comma separated list of Patient IDs that in sum contain all MUST SUPPORT elements", + "optional": true, + "title": "Patient IDs", + "type": "text", + "value": "pat015" + }, + { + "name": "device_ids", + "description": "Comma separated list of Device IDs that in sum contain all MUST SUPPORT elements", + "title": "Device IDs", + "type": "text", + "value": "example" + }, + { + "name": "encounter_ids", + "description": "Comma separated list of Encounter IDs that in sum contain all MUST SUPPORT elements", + "optional": true, + "title": "Encounter IDs", + "type": "text", + "value": "pat015-rad-encounter" + }, + { + "name": "organization_ids", + "description": "Comma separated list of Organization IDs that in sum contain all MUST SUPPORT elements", + "optional": true, + "title": "Organization IDs", + "type": "text", + "value": "org1234" + }, + { + "name": "practitioner_ids", + "description": "Comma separated list of Practitioner IDs that in sum contain all MUST SUPPORT elements", + "optional": true, + "title": "Practitioner IDs", + "type": "text", + "value": "pra1234" + }, + { + "name": "practitioner_role_ids", + "description": "Comma separated list of Practitioner IDs that in sum contain all MUST SUPPORT elements", + "optional": true, + "title": "PractitionerRole IDs", + "type": "text", + "value": "prarol1234" + }, + { + "name": "location_ids", + "description": "Comma separated list of Location IDs that in sum contain all MUST SUPPORT elements", + "title": "Location IDs", + "type": "text", + "value": "loc1234" + }, + { + "name": "appointment_update_resources", + "description": "Provide a list of resources to update. e.g., [json_resource_1, json_resource_2]", + "title": "Appointment Resources", + "type": "textarea", + "value": "[{\n \"resourceType\": \"Appointment\",\n \"id\": \"125\",\n \"meta\": {\n \"versionId\": \"1\",\n \"lastUpdated\": \"2024-05-18T17:20:39.156+00:00\"\n },\n \"status\": \"proposed\",\n \"serviceType\": [\n {\n \"coding\": [\n {\n \"system\": \"http://terminology.hl7.org/CodeSystem/service-type\",\n \"code\": \"183\",\n \"display\": \"Sleep Medicine\"\n }\n ]\n }\n ],\n \"appointmentType\": {\n \"coding\": [\n {\n \"system\": \"http://hl7.org/fhir/v2/0276\",\n \"code\": \"FOLLOWUP\",\n \"display\": \"A follow up visit from a previous appointment\"\n }\n ]\n },\n \"description\": \"CPAP adjustments\",\n \"start\": \"2019-08-10T09:00:00Z\",\n \"end\": \"2019-08-10T11:00:00Z\",\n \"created\": \"2019-08-01\",\n \"participant\": [\n {\n \"actor\": {\n \"reference\": \"Patient/pat014\",\n \"display\": \"Peter James Chalmers\"\n },\n \"required\": \"required\",\n \"status\": \"tentative\"\n },\n {\n \"actor\": {\n \"reference\": \"Practitioner/pra1255\",\n \"display\": \"Dr Adam Careful\"\n },\n \"required\": \"required\",\n \"status\": \"accepted\"\n }\n ],\n \"requestedPeriod\": [\n {\n \"start\": \"2020-05-23\",\n \"end\": \"2020-05-23\"\n }\n ]\n}]" + }, + { + "name": "communication_request_update_resources", + "description": "Provide a list of resources to update. e.g., [json_resource_1, json_resource_2]", + "title": "CommunicationRequest Resources", + "type": "textarea", + "value": "[\n {\n \"resourceType\": \"CommunicationRequest\",\n \"id\": \"134\",\n \"identifier\": [\n {\n \"system\": \"http://www.jurisdiction.com/insurer/123456\",\n \"value\": \"ABC123\"\n }\n ],\n \"basedOn\": [\n {\n \"display\": \"EligibilityRequest\"\n }\n ],\n \"groupIdentifier\": {\n \"value\": \"12345\"\n },\n \"status\": \"draft\",\n \"category\": [\n {\n \"coding\": [\n {\n \"system\": \"http://terminology.hl7.org/CodeSystem/communication-category\",\n \"code\": \"instruction\"\n }\n ]\n }\n ],\n \"priority\": \"routine\",\n \"medium\": [\n {\n \"coding\": [\n {\n \"system\": \"http://terminology.hl7.org/CodeSystem/v3-ParticipationMode\",\n \"code\": \"WRITTEN\",\n \"display\": \"written\"\n }\n ],\n \"text\": \"written\"\n }\n ],\n \"subject\": {\n \"reference\": \"Patient/pat015\"\n },\n \"encounter\": {\n \"reference\": \"Encounter/pat015-rad-encounter\"\n },\n \"payload\": [\n {\n \"extension\": [\n {\n \"url\": \"http://hl7.org/fhir/5.0/StructureDefinition/extension-CommunicationRequest.payload.content[x]\",\n \"valueCodeableConcept\": {\n \"coding\": [\n {\n \"system\": \"http://loinc.org\",\n \"code\": \"65752-8\",\n \"display\": \"Liver Pathology biopsy report\"\n }\n ]\n }\n }\n ],\n \"contentString\": \"Liver pathology biopsy report\"\n }\n ],\n \"occurrenceDateTime\": \"2016-06-10T11:01:10-08:00\",\n \"authoredOn\": \"2016-06-10T11:01:10-08:00\",\n \"requester\": {\n \"reference\": \"Practitioner/pra1234\"\n },\n \"recipient\": [\n {\n \"reference\": \"Organization/org1234\"\n }\n ]\n }\n]" + }, + { + "name": "device_request_update_resources", + "description": "Provide a list of resources to update. e.g., [json_resource_1, json_resource_2]", + "title": "DeviceRequest Resources", + "type": "textarea", + "value": "[{\n \"resourceType\": \"DeviceRequest\",\n \"id\": \"devreq014\",\n \"meta\": {\n \"versionId\": \"1\",\n \"lastUpdated\": \"2024-05-18T17:20:39.156+00:00\",\n \"profile\": [\n \"http://hl7.org/fhir/us/davinci-crd/R4/StructureDefinition/profile-devicerequest-r4\"\n ]\n },\n \"identifier\": [\n {\n \"type\": {\n \"coding\": [\n {\n \"system\": \"http://terminology.hl7.org/CodeSystem/v2-0203\",\n \"code\": \"PLAC\"\n }\n ]\n },\n \"value\": \"600dca50-ddae-482e-b8ac-383e1ab10c18\"\n }\n ],\n \"status\": \"draft\",\n \"intent\": \"original-order\",\n \"codeCodeableConcept\": {\n \"coding\": [\n {\n \"system\": \"https://bluebutton.cms.gov/resources/codesystem/hcpcs\",\n \"code\": \"E0424\",\n \"display\": \"Stationary Compressed Gaseous Oxygen System, Rental\"\n }\n ]\n },\n \"subject\": {\n \"reference\": \"Patient/pat014\"\n },\n \"authoredOn\": \"2023-01-01T00:00:00Z\",\n \"requester\": {\n \"reference\": \"Practitioner/pra-hfairchild\"\n },\n \"performer\": {\n \"reference\": \"Practitioner/pra1255\"\n },\n \"insurance\": [\n {\n \"reference\": \"Coverage/cov014\"\n }\n ]\n}]" + }, + { + "name": "encounter_update_resources", + "description": "Provide a list of resources to update. e.g., [json_resource_1, json_resource_2]", + "title": "Encounter Resources", + "type": "textarea", + "value": "[{\n \"resourceType\": \"Encounter\",\n \"id\": \"pat015-rad-encounter\",\n \"meta\": {\n \"versionId\": \"1\",\n \"lastUpdated\": \"2024-05-18T17:20:39.156+00:00\"\n },\n \"status\": \"finished\",\n \"class\": {\n \"system\": \"http://terminology.hl7.org/CodeSystem/v3-ActCode\",\n \"code\": \"HH\",\n \"display\": \"home health\"\n },\n \"type\": [\n {\n \"coding\": [\n {\n \"system\": \"http://snomed.info/sct\",\n \"code\": \"185345009\",\n \"display\": \"Encounter for symptom\"\n }\n ]\n }\n ],\n \"priority\": {\n \"coding\": [\n {\n \"system\": \"http://snomed.info/sct\",\n \"code\": \"709122007\",\n \"display\": \"As soon as possible (qualifier value)\"\n }\n ]\n },\n \"subject\": {\n \"reference\": \"Patient/pat015\",\n \"display\": \"Roosevelt Theodore\"\n },\n \"participant\": [\n {\n \"type\": [\n {\n \"coding\": [\n {\n \"system\": \"http://terminology.hl7.org/CodeSystem/v3-ParticipationType\",\n \"code\": \"PPRF\",\n \"display\": \"primary performer\"\n }\n ]\n }\n ],\n \"individual\": {\n \"reference\": \"Practitioner/pra1234\",\n \"display\": \"Dr. Jane Doe\"\n }\n }\n ],\n \"period\": {\n \"start\": \"2020-07-01T10:40:10+01:00\",\n \"end\": \"2020-07-01T12:40:10+01:00\"\n },\n \"length\": {\n \"value\": 56,\n \"unit\": \"minutes\",\n \"system\": \"http://unitsofmeasure.org\",\n \"code\": \"min\"\n },\n \"reasonCode\": [\n {\n \"coding\": [\n {\n \"system\": \"http://hl7.org/fhir/sid/icd-10-cm\",\n \"code\": \"J44.9\",\n \"display\": \"Chronic obstructive pulmonary disease, unspecified\"\n }\n ]\n }\n ],\n \"diagnosis\": [\n {\n \"condition\": {\n \"reference\": \"Condition/cond015a\",\n \"display\": \"The patient is hospitalized for stroke\"\n },\n \"use\": {\n \"coding\": [\n {\n \"system\": \"http://terminology.hl7.org/CodeSystem/diagnosis-role\",\n \"code\": \"AD\",\n \"display\": \"Admission diagnosis\"\n }\n ]\n },\n \"rank\": 2\n },\n {\n \"condition\": {\n \"reference\": \"Condition/cond015a\",\n \"display\": \"The patient is hospitalized for lung condition\"\n },\n \"use\": {\n \"coding\": [\n {\n \"system\": \"http://terminology.hl7.org/CodeSystem/diagnosis-role\",\n \"code\": \"CC\",\n \"display\": \"Chief complaint\"\n }\n ]\n },\n \"rank\": 1\n }\n ]\n}]" + }, + { + "name": "medication_request_update_resources", + "description": "Provide a list of resources to update. e.g., [json_resource_1, json_resource_2]", + "title": "MedicationRequest Resources", + "type": "textarea", + "value": "[{\n \"resourceType\": \"MedicationRequest\",\n \"id\": \"pat014-mr-azathioprine\",\n \"meta\": {\n \"versionId\": \"1\",\n \"lastUpdated\": \"2024-05-18T17:20:39.156+00:00\"\n },\n \"identifier\": [\n {\n \"type\": {\n \"coding\": [\n {\n \"system\": \"http://terminology.hl7.org/CodeSystem/v2-0203\",\n \"code\": \"PLAC\"\n }\n ]\n },\n \"value\": \"25839b73-fcc9-4706-8c77-a806995b8109\"\n }\n ],\n \"status\": \"active\",\n \"intent\": \"order\",\n \"medicationCodeableConcept\": {\n \"coding\": [\n {\n \"system\": \"http://www.nlm.nih.gov/research/umls/rxnorm\",\n \"code\": \"105611\",\n \"display\": \"azathioprine 50 MG Oral Tablet [Imuran]\"\n }\n ]\n },\n \"subject\": {\n \"reference\": \"Patient/pat014\",\n \"display\": \"Theodor Roosevelt\"\n },\n \"authoredOn\": \"2020-05-11\",\n \"requester\": {\n \"reference\": \"Practitioner/pra1234\",\n \"display\": \"Jane Doe\"\n },\n \"reasonCode\": [\n {\n \"coding\": [\n {\n \"system\": \"http://snomed.info/sct\",\n \"code\": \"52042003\",\n \"display\": \"Systemic lupus erythematosus glomerulonephritis syndrome, World Health Organization class V (disorder)\"\n }\n ]\n }\n ],\n \"insurance\": [\n {\n \"reference\": \"Coverage/cov014\"\n }\n ],\n \"dosageInstruction\": [\n {\n \"sequence\": 1,\n \"text\": \"50 mg PO daily for remission induction\",\n \"timing\": {\n \"repeat\": {\n \"frequency\": 1,\n \"period\": 1,\n \"periodUnit\": \"d\"\n }\n },\n \"route\": {\n \"coding\": [\n {\n \"system\": \"http://snomed.info/sct\",\n \"code\": \"26643006\",\n \"display\": \"Oral route (qualifier value)\"\n }\n ]\n },\n \"doseAndRate\": [\n {\n \"type\": {\n \"coding\": [\n {\n \"system\": \"http://terminology.hl7.org/CodeSystem/dose-rate-type\",\n \"code\": \"ordered\",\n \"display\": \"Ordered\"\n }\n ]\n },\n \"doseQuantity\": {\n \"value\": 50,\n \"unit\": \"mg\",\n \"system\": \"http://unitsofmeasure.org\",\n \"code\": \"mg\"\n }\n }\n ]\n }\n ],\n \"dispenseRequest\": {\n \"numberOfRepeatsAllowed\": 3,\n \"quantity\": {\n \"value\": 90,\n \"system\": \"http://terminology.hl7.org/CodeSystem/v3-orderableDrugForm\",\n \"code\": \"TAB\"\n }\n }\n}]" + }, + { + "name": "nutrition_order_update_resources", + "description": "Provide a list of resources to update. e.g., [json_resource_1, json_resource_2]", + "title": "NutritionOrder Resources", + "type": "textarea", + "value": "[{\n \"resourceType\": \"NutritionOrder\",\n \"id\": \"135\",\n \"meta\": {\n \"versionId\": \"1\",\n \"lastUpdated\": \"2024-05-23T15:32:28.628+00:00\"\n },\n \"identifier\": [\n {\n \"system\": \"http://goodhealthhospital.org/nutrition-requests\",\n \"value\": \"123\"\n }\n ],\n \"status\": \"draft\",\n \"intent\": \"order\",\n \"patient\": {\n \"reference\": \"Patient/pat014\"\n },\n \"encounter\": {\n \"reference\": \"Encounter/enc-pat014\"\n },\n \"dateTime\": \"2014-09-17\",\n \"orderer\": {\n \"reference\": \"Practitioner/pra1234\"\n },\n \"allergyIntolerance\": [\n {\n \"reference\": \"AllergyIntolerance/allergy-pat014-cashew\",\n \"display\": \"Cashew Nuts\"\n }\n ],\n \"foodPreferenceModifier\": [\n {\n \"coding\": [\n {\n \"system\": \"http://terminology.hl7.org/CodeSystem/diet\",\n \"code\": \"dairy-free\"\n }\n ]\n }\n ],\n \"excludeFoodModifier\": [\n {\n \"coding\": [\n {\n \"system\": \"http://snomed.info/sct\",\n \"code\": \"227493005\",\n \"display\": \"Cashew Nut\"\n }\n ]\n }\n ],\n \"oralDiet\": {\n \"type\": [\n {\n \"coding\": [\n {\n \"system\": \"http://snomed.info/sct\",\n \"code\": \"15108003\",\n \"display\": \"Restricted fiber diet\"\n }\n ],\n \"text\": \"Fiber restricted diet\"\n },\n {\n \"coding\": [\n {\n \"system\": \"http://snomed.info/sct\",\n \"code\": \"16208003\",\n \"display\": \"Low fat diet\"\n }\n ],\n \"text\": \"Low fat diet\"\n }\n ],\n \"schedule\": [\n {\n \"repeat\": {\n \"boundsPeriod\": {\n \"start\": \"2015-02-10\"\n },\n \"frequency\": 3,\n \"period\": 1,\n \"periodUnit\": \"d\"\n }\n }\n ],\n \"nutrient\": [\n {\n \"modifier\": {\n \"coding\": [\n {\n \"system\": \"http://snomed.info/sct\",\n \"code\": \"256674009\",\n \"display\": \"Fat\"\n }\n ]\n },\n \"amount\": {\n \"value\": 50,\n \"unit\": \"grams\",\n \"system\": \"http://unitsofmeasure.org\",\n \"code\": \"g\"\n }\n }\n ]\n }\n}]" + }, + { + "name": "service_request_update_resources", + "description": "Provide a list of resources to update. e.g., [json_resource_1, json_resource_2]", + "title": "ServiceRequest Resources", + "type": "textarea", + "value": "[{\n \"resourceType\": \"ServiceRequest\",\n \"id\": \"servreq01\",\n \"meta\": {\n \"versionId\": \"1\",\n \"lastUpdated\": \"2024-05-18T17:20:39.156+00:00\"\n },\n \"identifier\": [\n {\n \"type\": {\n \"coding\": [\n {\n \"system\": \"http://terminology.hl7.org/CodeSystem/v2-0203\",\n \"code\": \"PLAC\"\n }\n ]\n },\n \"value\": \"63dae4d5-1b48-41f6-b56c-8b2fdea87067\"\n }\n ],\n \"status\": \"draft\",\n \"intent\": \"order\",\n \"category\": [\n {\n \"coding\": [\n {\n \"system\": \"http://snomed.info/sct\",\n \"code\": \"386053000\",\n \"display\": \"Evaluation procedure (procedure)\"\n }\n ],\n \"text\": \"Evaluation\"\n }\n ],\n \"code\": {\n \"coding\": [\n {\n \"system\": \"https://bluebutton.cms.gov/resources/codesystem/hcpcs\",\n \"code\": \"A0426\",\n \"display\": \"Ambulance service, advanced life support, non-emergency transport, level 1 (als 1)\"\n }\n ],\n \"text\": \"Ambulance service Non-Emergency Transport\"\n },\n \"subject\": {\n \"reference\": \"Patient/pat014\"\n },\n \"occurrenceDateTime\": \"2016-09-27\",\n \"authoredOn\": \"2016-09-20\",\n \"requester\": {\n \"display\": \"Smythe Juliette, MD\"\n },\n \"performer\": [\n {\n \"reference\": \"Practitioner/pra1255\"\n }\n ],\n \"reasonCode\": [\n {\n \"text\": \"Physical Therapy for Hip Fracture \"\n }\n ],\n \"insurance\": [\n {\n \"reference\": \"Coverage/cov014\"\n }\n ]\n}]" + }, + { + "name": "claim_response_create_resources", + "description": "Provide a list of resources to create. e.g., [json_resource_1, json_resource_2]", + "title": "ClaimResponse Resources", + "type": "textarea", + "value": "[{\n \"resourceType\": \"ClaimResponse\",\n \"status\": \"active\",\n \"type\": {\n \"coding\": [\n {\n \"system\": \"http://terminology.hl7.org/CodeSystem/claim-type\",\n \"code\": \"professional\"\n }\n ]\n },\n \"use\": \"preauthorization\",\n \"patient\": {\n \"reference\": \"Patient/pat015\"\n },\n \"created\": \"2023-05-02T11:02:00+05:00\",\n \"insurer\": {\n \"reference\": \"Organization/org1234\"\n },\n \"requestor\": {\n \"reference\": \"Practitioner/pra1234\"\n },\n \"outcome\": \"complete\",\n \"preAuthRef\": \"A1B2C3D4\",\n \"addItem\": [\n {\n \"extension\": [\n {\n \"url\": \"http://hl7.org/fhir/us/davinci-hrex/StructureDefinition/extension-itemAuthorizedDate\",\n \"valuePeriod\": {\n \"start\": \"2005-05-02\",\n \"end\": \"2005-06-02\"\n }\n },\n {\n \"url\": \"http://hl7.org/fhir/us/davinci-hrex/StructureDefinition/extension-itemPreAuthIssueDate\",\n \"valueDate\": \"2005-05-02\"\n },\n {\n \"url\": \"http://hl7.org/fhir/us/davinci-hrex/StructureDefinition/extension-itemAuthorizedProvider\",\n \"valueReference\": {\n \"reference\": \"Practitioner/pra1234\"\n }\n }\n ],\n \"itemSequence\": [\n 1\n ],\n \"productOrService\": {\n \"coding\": [\n {\n \"system\": \"http://codesystem.x12.org/005010/1365\",\n \"code\": \"3\",\n \"display\": \"Consultation\"\n }\n ]\n },\n \"locationCodeableConcept\": {\n \"coding\": [\n {\n \"system\": \"https://www.cms.gov/Medicare/Coding/place-of-service-codes/Place_of_Service_Code_Set\",\n \"code\": \"11\"\n }\n ]\n },\n \"adjudication\": [\n {\n \"extension\": [\n {\n \"url\": \"http://hl7.org/fhir/us/davinci-hrex/StructureDefinition/extension-reviewAction\",\n \"extension\": [\n {\n \"url\": \"number\",\n \"valueString\": \"AUTH0001\"\n },\n {\n \"url\": \"http://hl7.org/fhir/us/davinci-hrex/StructureDefinition/extension-reviewActionCode\",\n \"valueCodeableConcept\": {\n \"coding\": [\n {\n \"system\": \"http://codesystem.x12.org/005010/306\",\n \"code\": \"A1\",\n \"display\": \"Certified in total\"\n }\n ]\n }\n }\n ]\n }\n ],\n \"category\": {\n \"coding\": [\n {\n \"system\": \"http://terminology.hl7.org/CodeSystem/adjudication\",\n \"code\": \"submitted\"\n }\n ]\n }\n }\n ]\n }\n ]\n}]" + }, + { + "name": "task_create_resources", + "description": "Provide a list of resources to create. e.g., [json_resource_1, json_resource_2]", + "title": "Task Resources", + "type": "textarea", + "value": "[\n {\n \"resourceType\": \"Task\",\n \"basedOn\": [\n {\n \"reference\": \"MedicationRequest/pat014-mr-azathioprine\"\n }\n ],\n \"status\": \"ready\",\n \"intent\": \"order\",\n \"code\": {\n \"coding\": [\n {\n \"system\": \"http://hl7.org/fhir/uv/sdc/CodeSystem/temp\",\n \"code\": \"complete-questionnaire\"\n }\n ]\n },\n \"for\": {\n \"reference\": \"Patient/pat015\"\n },\n \"encounter\": {\n \"reference\": \"Encounter/pat015-rad-encounter\"\n },\n \"authoredOn\": \"2018-08-09\",\n \"requester\": {\n \"reference\": \"Organization/org1234\"\n },\n \"reasonCode\": {\n \"coding\": [\n {\n \"system\": \"http://hl7.org/fhir/us/davinci-crd/CodeSystem/temp\",\n \"code\": \"reason-prior-auth\"\n }\n ],\n \"text\": \"Needed for prior authorization\"\n },\n \"input\": [\n {\n \"type\": {\n \"coding\": [\n {\n \"system\": \"http://hl7.org/fhir/uv/sdc/CodeSystem/temp\",\n \"code\": \"questionnaire\"\n }\n ]\n },\n \"valueCanonical\": \"https://crd-test.davinci.hl7.org/test-ehr/r4/Questionnaire/cdex-questionnaire-example1\"\n },\n {\n \"type\": {\n \"coding\": [\n {\n \"system\": \"http://hl7.org/fhir/uv/sdc/CodeSystem/temp\",\n \"code\": \"response-endpoint\"\n }\n ]\n },\n \"valueUrl\": \"https://crd-test.davinci.hl7.org/test-ehr\"\n },\n {\n \"type\": {\n \"coding\": [\n {\n \"system\": \"http://hl7.org/fhir/us/davinci-crd/CodeSystem/temp\",\n \"code\": \"after-completion-action\"\n }\n ]\n },\n \"valueCodeableConcept\": {\n \"coding\": [\n {\n \"system\": \"http://hl7.org/fhir/us/davinci-crd/CodeSystem/temp\",\n \"code\": \"prior-auth-include\",\n \"display\": \"Include in prior authorization\"\n }\n ]\n }\n }\n ]\n }\n]" + }, + { + "name": "vision_prescription_update_resources", + "description": "Provide a list of resources to update. e.g., [json_resource_1, json_resource_2]", + "title": "VisionPrescription Resources", + "type": "textarea", + "value": "[{\n \"resourceType\": \"VisionPrescription\",\n \"id\": \"136\",\n \"meta\": {\n \"versionId\": \"1\",\n \"lastUpdated\": \"2024-05-23T15:34:54.580+00:00\"\n },\n \"identifier\": [\n {\n \"system\": \"http://www.happysight.com/prescription\",\n \"value\": \"15013\"\n }\n ],\n \"status\": \"draft\",\n \"created\": \"2014-06-15\",\n \"patient\": {\n \"reference\": \"Patient/pat015\"\n },\n \"dateWritten\": \"2014-06-15\",\n \"prescriber\": {\n \"reference\": \"Practitioner/pra1234\"\n },\n \"lensSpecification\": [\n {\n \"product\": {\n \"coding\": [\n {\n \"system\": \"http://terminology.hl7.org/CodeSystem/ex-visionprescriptionproduct\",\n \"code\": \"lens\"\n }\n ]\n },\n \"eye\": \"right\",\n \"sphere\": -2,\n \"prism\": [\n {\n \"amount\": 0.5,\n \"base\": \"down\"\n }\n ],\n \"add\": 2\n },\n {\n \"product\": {\n \"coding\": [\n {\n \"system\": \"http://terminology.hl7.org/CodeSystem/ex-visionprescriptionproduct\",\n \"code\": \"lens\"\n }\n ]\n },\n \"eye\": \"left\",\n \"sphere\": -1,\n \"cylinder\": -0.5,\n \"axis\": 180,\n \"prism\": [\n {\n \"amount\": 0.5,\n \"base\": \"up\"\n }\n ],\n \"add\": 2\n }\n ]\n}]" + } + ] +} diff --git a/config/presets/ri_crd_request_generator.json.erb b/config/presets/ri_crd_request_generator.json.erb new file mode 100644 index 0000000..921392d --- /dev/null +++ b/config/presets/ri_crd_request_generator.json.erb @@ -0,0 +1,74 @@ +{ + "title": "CRD Request Generator RI", + "id": "ri_crd_request_generator", + "test_suite_id": "crd_client", + "inputs": [ + { + "name": "iss", + "title": "URI of the issuer of the JWT used to authorize CDS Hooks calls", + "type": "text", + "value": "<%= ENV.fetch('RI_CRD_REQUEST_GENERATOR_URI', 'https://crd-test.davinci.hl7.org') %>/test-ehr/r4" + }, + { + "name": "url", + "description": "URL of the FHIR endpoint used by SMART applications", + "title": "FHIR Endpoint", + "type": "text", + "value": "<%= ENV.fetch('RI_CRD_REQUEST_GENERATOR_URI', 'https://crd-test.davinci.hl7.org') %>/test-ehr/r4" + }, + { + "name": "patient_ids", + "description": "Comma separated list of Patient IDs that in sum contain all MUST SUPPORT elements", + "optional": true, + "title": "Patient IDs", + "type": "text", + "value": "pat015" + }, + { + "name": "device_ids", + "description": "Comma separated list of Device IDs that in sum contain all MUST SUPPORT elements", + "title": "Device IDs", + "type": "text", + "value": "example" + }, + { + "name": "encounter_ids", + "description": "Comma separated list of Encounter IDs that in sum contain all MUST SUPPORT elements", + "optional": true, + "title": "Encounter IDs", + "type": "text", + "value": "pat015-rad-encounter" + }, + { + "name": "organization_ids", + "description": "Comma separated list of Organization IDs that in sum contain all MUST SUPPORT elements", + "optional": true, + "title": "Organization IDs", + "type": "text", + "value": "org1234" + }, + { + "name": "practitioner_ids", + "description": "Comma separated list of Practitioner IDs that in sum contain all MUST SUPPORT elements", + "optional": true, + "title": "Practitioner IDs", + "type": "text", + "value": "pra1234" + }, + { + "name": "practitioner_role_ids", + "description": "Comma separated list of Practitioner IDs that in sum contain all MUST SUPPORT elements", + "optional": true, + "title": "PractitionerRole IDs", + "type": "text", + "value": "prarol1234" + }, + { + "name": "location_ids", + "description": "Comma separated list of Location IDs that in sum contain all MUST SUPPORT elements", + "title": "Location IDs", + "type": "text", + "value": "loc1234" + } + ] +} diff --git a/config/presets/ri_crd_server.json.erb b/config/presets/ri_crd_server.json.erb new file mode 100644 index 0000000..ea2c0bb --- /dev/null +++ b/config/presets/ri_crd_server.json.erb @@ -0,0 +1,108 @@ +{ + "title": "CRD Server RI", + "id": "crd-server-ri", + "test_suite_id": "crd_server", + "inputs": [ + { + "name": "base_url", + "title": "CRD server base URL", + "type": "text", + "value": "<%= ENV.fetch('RI_CRD_SERVER_URI', 'https://crd.davinci.hl7.org') %>/r4" + }, + { + "name": "authentication_required", + "default": "no", + "options": { + "list_options": [ + { + "label": "No", + "value": "no" + }, + { + "label": "Yes", + "value": "yes" + } + ] + }, + "title": "Discovery endpoint requires authentication?", + "type": "radio", + "value": "no" + }, + { + "name": "encryption_method", + "default": "ES384", + "description": "CDS Hooks recommends ES384 and RS384 for JWT signature verification. Select which method to use.", + "options": { + "list_options": [ + { + "label": "ES384", + "value": "ES384" + }, + { + "label": "RS384", + "value": "RS384" + } + ] + }, + "title": "JWT Signing Algorithm", + "type": "radio", + "value": "ES384" + }, + { + "name": "jwks_kid", + "description": "The key ID of the JWKS private key to use for signing the JWTs when invoking a CDS service endpoint requiring authentication. Defaults to the first JWK in the list if no kid is supplied.", + "optional": true, + "title": "CDS Services JWKS kid", + "type": "text", + "value": "" + }, + { + "name": "appointment_book_request_bodies", + "default": "[\n {\n \"hookInstance\": \"d1577c69-dfbe-44ad-ba6d-3e05e953b2ea\",\n \"fhirServer\": \"https://inferno-qa.healthit.gov/reference-server/r4\",\n \"hook\": \"appointment-book\",\n \"fhirAuthorization\": {\n \"access_token\": \"SAMPLE_TOKEN\",\n \"token_type\": \"Bearer\",\n \"expires_in\": 300,\n \"scope\": \"user/Patient.read user/Observation.read\",\n \"subject\": \"cds-service\"\n },\n \"context\": {\n \"userId\": \"Practitioner/pra1255\",\n \"patientId\": \"pat015\",\n \"encounterId\": \"enc-pat014\",\n \"appointments\": {\n \"resourceType\": \"Bundle\",\n \"id\": \"2f5d7a40-31c1-4daa-9670-e0e7c748e395\",\n \"meta\": {\n \"lastUpdated\": \"2024-05-10T03:26:08.496+00:00\"\n },\n \"type\": \"searchset\",\n \"total\": 1,\n \"link\": [\n {\n \"relation\": \"self\",\n \"url\": \"https://inferno-qa.healthit.gov/reference-server/r4/Appointment?patient=pat015\"\n }\n ],\n \"entry\": [\n {\n \"fullUrl\": \"https://inferno-qa.healthit.gov/reference-server/r4/Appointment/124\",\n \"resource\": {\n \"resourceType\": \"Appointment\",\n \"id\": \"124\",\n \"meta\": {\n \"versionId\": \"1\",\n \"lastUpdated\": \"2024-05-10T03:25:21.327+00:00\",\n \"source\": \"#meQ6e6aw4020GBer\"\n },\n \"text\": {\n \"status\": \"generated\",\n \"div\": \"

Generated Narrative: Appointment

Resource Appointment "example"

status: proposed

serviceCategory: General Practice (Service category#17)

serviceType: General Practice (Service type#pat015)

specialty: General practice (specialty) (SNOMED CT#394814009)

appointmentType: A follow up visit from a previous appointment (appointmentReason#FOLLOWUP)

reasonReference: Condition/cond015a: Heart problem

priority: 5

description: Discussion on the results of your recent MRI

start: Dec 10, 2013, 9:00:00 AM

end: Dec 10, 2013, 11:00:00 AM

created: 2013-10-10

comment: Further expand on the results of the MRI and determine the next actions that may be appropriate.

basedOn: ServiceRequest/servreq-g0180-1

participant

actor: Patient/pat015: Amy Baxter " SHAW"

required: required

status: accepted

participant

type: attender (ParticipationType#ATND)

actor: Practitioner/pra1255: Dr Adam Careful " CAREFUL"

required: required

status: accepted

participant

actor: Location/example: South Wing, second floor "South Wing, second floor"

required: required

status: accepted

requestedPeriod: 2020-11-01 --> 2020-12-15

\"\n },\n \"status\": \"proposed\",\n \"serviceCategory\": [\n {\n \"coding\": [\n {\n \"system\": \"http://terminology.hl7.org/CodeSystem/service-category\",\n \"code\": \"17\",\n \"display\": \"General Practice\"\n }\n ]\n }\n ],\n \"serviceType\": [\n {\n \"coding\": [\n {\n \"system\": \"http://terminology.hl7.org/CodeSystem/service-type\",\n \"code\": \"124\",\n \"display\": \"General Practice\"\n }\n ]\n }\n ],\n \"specialty\": [\n {\n \"coding\": [\n {\n \"system\": \"http://snomed.info/sct\",\n \"code\": \"394814009\",\n \"display\": \"General practice (specialty)\"\n }\n ]\n }\n ],\n \"appointmentType\": {\n \"coding\": [\n {\n \"system\": \"http://terminology.hl7.org/CodeSystem/v2-0276\",\n \"code\": \"FOLLOWUP\",\n \"display\": \"A follow up visit from a previous appointment\"\n }\n ]\n },\n \"reasonReference\": [\n {\n \"reference\": \"Condition/cond015a\",\n \"display\": \"Heart problem\"\n }\n ],\n \"priority\": 5,\n \"description\": \"Discussion on the results of your recent MRI\",\n \"start\": \"2013-12-10T09:00:00Z\",\n \"end\": \"2013-12-10T11:00:00Z\",\n \"created\": \"2013-10-10\",\n \"comment\": \"Further expand on the results of the MRI and determine the next actions that may be appropriate.\",\n \"basedOn\": [\n {\n \"reference\": \"ServiceRequest/servreq-g0180-1\"\n }\n ],\n \"participant\": [\n {\n \"actor\": {\n \"reference\": \"Patient/pat015\",\n \"display\": \"Amy Baxter\"\n },\n \"required\": \"required\",\n \"status\": \"accepted\"\n },\n {\n \"type\": [\n {\n \"coding\": [\n {\n \"system\": \"http://terminology.hl7.org/CodeSystem/v3-ParticipationType\",\n \"code\": \"ATND\"\n }\n ]\n }\n ],\n \"actor\": {\n \"reference\": \"Practitioner/pra1255\",\n \"display\": \"Dr Adam Careful\"\n },\n \"required\": \"required\",\n \"status\": \"accepted\"\n },\n {\n \"actor\": {\n \"reference\": \"Location/loc1234\",\n \"display\": \"South Wing, second floor\"\n },\n \"required\": \"required\",\n \"status\": \"accepted\"\n }\n ],\n \"requestedPeriod\": [\n {\n \"start\": \"2020-11-01\",\n \"end\": \"2020-12-15\"\n }\n ]\n },\n \"search\": {\n \"mode\": \"match\"\n }\n }\n ]\n }\n },\n \"prefetch\": {\n \"encounterBundle\": {\n \"resourceType\": \"Bundle\",\n \"id\": \"8ffa667f-77ce-41b7-a873-da447793a195\",\n \"meta\": {\n \"lastUpdated\": \"2024-05-10T03:33:16.240+00:00\"\n },\n \"type\": \"searchset\",\n \"total\": 1,\n \"link\": [\n {\n \"relation\": \"self\",\n \"url\": \"https://inferno-qa.healthit.gov/reference-server/r4/Encounter?_id=enc-pat014&_include=Encounter%3Apatient&_include=Encounter%3Aservice-provider&_include=Encounter%3Apractitioner&_include=Encounter%3Alocation\"\n }\n ],\n \"entry\": [\n {\n \"fullUrl\": \"https://inferno-qa.healthit.gov/reference-server/r4/Encounter/enc-pat014\",\n \"resource\": {\n \"resourceType\": \"Encounter\",\n \"id\": \"enc-pat014\",\n \"meta\": {\n \"versionId\": \"1\",\n \"lastUpdated\": \"2024-04-25T17:48:34.732+00:00\",\n \"source\": \"#RVwRUek6bQC44Wa9\"\n },\n \"status\": \"finished\",\n \"class\": {\n \"system\": \"http://terminology.hl7.org/CodeSystem/v3-ActCode\",\n \"code\": \"AMB\",\n \"display\": \"ambulatory\"\n },\n \"type\": [\n {\n \"coding\": [\n {\n \"system\": \"http://snomed.info/sct\",\n \"code\": \"185349003\",\n \"display\": \"Encounter for check up\"\n }\n ]\n }\n ],\n \"priority\": {\n \"coding\": [\n {\n \"system\": \"http://snomed.info/sct\",\n \"code\": \"103391001\",\n \"display\": \"Urgent\"\n }\n ]\n },\n \"subject\": {\n \"reference\": \"Patient/pat014\",\n \"display\": \"Teddy\"\n },\n \"participant\": [\n {\n \"individual\": {\n \"reference\": \"Practitioner/pra1234\"\n }\n }\n ],\n \"period\": {\n \"start\": \"2020-01-15T12:40:10+01:00\",\n \"end\": \"2020-01-15T13:40:10+01:00\"\n },\n \"length\": {\n \"value\": 56,\n \"unit\": \"minutes\",\n \"system\": \"http://unitsofmeasure.org\",\n \"code\": \"min\"\n },\n \"reasonCode\": [\n {\n \"coding\": [\n {\n \"system\": \"http://snomed.info/sct\",\n \"code\": \"42343007\",\n \"display\": \"Congestive heart failure (disorder)\"\n }\n ]\n }\n ],\n \"diagnosis\": [\n {\n \"condition\": {\n \"reference\": \"Condition/cond014a\",\n \"display\": \"Complications from infection on January 9th, 2020\"\n },\n \"use\": {\n \"coding\": [\n {\n \"system\": \"http://terminology.hl7.org/CodeSystem/diagnosis-role\",\n \"code\": \"AD\",\n \"display\": \"Admission diagnosis\"\n }\n ]\n },\n \"rank\": 2\n },\n {\n \"condition\": {\n \"reference\": \"Condition/cond014a\",\n \"display\": \"The patient is treated for wheezing\"\n },\n \"use\": {\n \"coding\": [\n {\n \"system\": \"http://terminology.hl7.org/CodeSystem/diagnosis-role\",\n \"code\": \"CC\",\n \"display\": \"Chief complaint\"\n }\n ]\n },\n \"rank\": 1\n }\n ]\n },\n \"search\": {\n \"mode\": \"match\"\n }\n },\n {\n \"fullUrl\": \"https://inferno-qa.healthit.gov/reference-server/r4/Practitioner/pra1234\",\n \"resource\": {\n \"resourceType\": \"Practitioner\",\n \"id\": \"pra1234\",\n \"meta\": {\n \"versionId\": \"1\",\n \"lastUpdated\": \"2024-04-25T17:48:33.625+00:00\",\n \"source\": \"#3fLOP5tSuZrGDiae\",\n \"profile\": [\n \"http://hl7.org/fhir/us/core/StructureDefinition/us-core-practitioner\"\n ]\n },\n \"identifier\": [\n {\n \"system\": \"http://hl7.org/fhir/sid/us-npi\",\n \"value\": \"1122334455\"\n }\n ],\n \"name\": [\n {\n \"use\": \"official\",\n \"family\": \"Doe\",\n \"given\": [\n \"Jane\",\n \"Betty\"\n ],\n \"prefix\": [\n \"Dr.\"\n ]\n }\n ],\n \"telecom\": [\n {\n \"system\": \"phone\",\n \"value\": \"716-873-1557\"\n },\n {\n \"system\": \"email\",\n \"value\": \"jane.betty@myhospital.com\"\n }\n ],\n \"address\": [\n {\n \"use\": \"home\",\n \"type\": \"both\",\n \"line\": [\n \"840 Seneca St\"\n ],\n \"city\": \"Buffalo\",\n \"state\": \"NY\",\n \"postalCode\": \"14210\"\n }\n ],\n \"qualification\": [\n {\n \"identifier\": [\n {\n \"system\": \"http://example.org/UniversityIdentifier\",\n \"value\": \"12345\"\n }\n ],\n \"code\": {\n \"coding\": [\n {\n \"system\": \"http://terminology.hl7.org/CodeSystem/v2-0360/2.7\",\n \"code\": \"MD\",\n \"display\": \"Doctor of Medicine\"\n }\n ],\n \"text\": \"Doctor of Medicine\"\n },\n \"period\": {\n \"start\": \"1995\"\n },\n \"issuer\": {\n \"display\": \"Example University\"\n }\n }\n ]\n },\n \"search\": {\n \"mode\": \"include\"\n }\n },\n {\n \"fullUrl\": \"https://inferno-qa.healthit.gov/reference-server/r4/Patient/pat014\",\n \"resource\": {\n \"resourceType\": \"Patient\",\n \"id\": \"pat014\",\n \"meta\": {\n \"versionId\": \"1\",\n \"lastUpdated\": \"2024-04-25T17:48:34.662+00:00\",\n \"source\": \"#4RE6S8FR2Y44APk0\"\n },\n \"text\": {\n \"status\": \"generated\",\n \"div\": \"
Theodor Alan Roosevelt ROOSEVELT
Identifier0M846129001NF
Address7525 Colshire Dr
McLean VA
Date of birth04 July 1946
\"\n },\n \"identifier\": [\n {\n \"use\": \"usual\",\n \"type\": {\n \"coding\": [\n {\n \"system\": \"http://terminology.hl7.org/CodeSystem/v2-0203\",\n \"code\": \"MR\"\n }\n ],\n \"text\": \"Medical Record Number\"\n },\n \"system\": \"http://hl7.org/fhir/sid/us-medicare\",\n \"value\": \"0M846129001NF\"\n }\n ],\n \"name\": [\n {\n \"use\": \"official\",\n \"family\": \"Roosevelt\",\n \"given\": [\n \"Theodor\",\n \"Alan\",\n \"Roosevelt\"\n ]\n }\n ],\n \"gender\": \"male\",\n \"birthDate\": \"1946-07-04\",\n \"address\": [\n {\n \"use\": \"home\",\n \"type\": \"both\",\n \"line\": [\n \"7525 Colshire Dr\"\n ],\n \"city\": \"McLean\",\n \"state\": \"VA\",\n \"postalCode\": \"22102\"\n }\n ]\n },\n \"search\": {\n \"mode\": \"include\"\n }\n }\n ]\n },\n \"patient\": {\n \"resourceType\": \"Patient\",\n \"id\": \"pat015\",\n \"meta\": {\n \"versionId\": \"1\",\n \"lastUpdated\": \"2024-04-25T17:48:34.068+00:00\",\n \"source\": \"#fT6fENtjF2A8Ne61\"\n },\n \"text\": {\n \"status\": \"generated\",\n \"div\": \"
William Hale Oster OSTER
Identifier0M34355006FW
Address202 Burlington Road
Bedford MA
Date of birth23 February 2015
\"\n },\n \"identifier\": [\n {\n \"use\": \"usual\",\n \"type\": {\n \"coding\": [\n {\n \"system\": \"http://terminology.hl7.org/CodeSystem/v2-0203\",\n \"code\": \"MR\"\n }\n ],\n \"text\": \"Medical Record Number\"\n },\n \"system\": \"http://hl7.org/fhir/sid/us-medicare\",\n \"value\": \"0M34355006FW\"\n }\n ],\n \"name\": [\n {\n \"use\": \"official\",\n \"family\": \"Oster\",\n \"given\": [\n \"William\",\n \"Hale\",\n \"Oster\"\n ]\n }\n ],\n \"telecom\": [\n {\n \"system\": \"phone\",\n \"value\": \"(781) 555-5555\",\n \"use\": \"home\",\n \"rank\": 1\n },\n {\n \"system\": \"phone\",\n \"value\": \"(781) 555 5613\",\n \"use\": \"work\",\n \"rank\": 2\n },\n {\n \"system\": \"phone\",\n \"value\": \"(781) 555 8834\",\n \"use\": \"old\",\n \"period\": {\n \"end\": \"2014\"\n }\n }\n ],\n \"gender\": \"male\",\n \"birthDate\": \"2015-02-23\",\n \"address\": [\n {\n \"use\": \"home\",\n \"type\": \"both\",\n \"line\": [\n \"202 Burlington Road\"\n ],\n \"city\": \"Bedford\",\n \"state\": \"MA\",\n \"postalCode\": \"01730\"\n }\n ]\n },\n \"coverageBundle\": {\n \"resourceType\": \"Bundle\",\n \"type\": \"collection\",\n \"entry\": [\n {\n \"fullUrl\": \"https://inferno-qa.healthit.gov/reference-server/r4/Coverage/cov015\",\n \"resource\": {\n \"resourceType\": \"Coverage\",\n \"id\": \"cov015\",\n \"meta\": {\n \"versionId\": \"1\",\n \"lastUpdated\": \"2024-05-08T09:47:16.618-04:00\",\n \"source\": \"#2oewiwPOJlURF4aM\"\n },\n \"status\": \"active\",\n \"subscriberId\": \"10A3D58WH456\",\n \"beneficiary\": {\n \"reference\": \"Patient/pat015\"\n },\n \"payor\": [\n {\n \"reference\": \"Organization/org1234\"\n }\n ],\n \"class\": [\n {\n \"type\": {\n \"coding\": [\n {\n \"system\": \"http://hl7.org/fhir/coverage-class\",\n \"code\": \"plan\"\n }\n ]\n },\n \"value\": \"Medicare Part A\"\n }\n ]\n },\n \"search\": {\n \"mode\": \"match\"\n }\n }\n ]\n }\n },\n \"extension\": {\n \"davinci-crd.configuration\": {\n \"coverage\": true,\n \"max-cards\": 10\n }\n }\n },\n {\n \"hookInstance\": \"d1577c69-dfbe-44ad-ba6d-3e05e953b2ea\",\n \"fhirServer\": \"https://inferno-qa.healthit.gov/reference-server/r4\",\n \"hook\": \"appointment-book\",\n \"fhirAuthorization\": {\n \"access_token\": \"SAMPLE_TOKEN\",\n \"token_type\": \"Bearer\",\n \"expires_in\": 300,\n \"scope\": \"user/Patient.read user/Observation.read\",\n \"subject\": \"cds-service\"\n },\n \"context\": {\n \"userId\": \"Practitioner/pra1255\",\n \"patientId\": \"pat014\",\n \"encounterId\": \"enc-pat014-cold\",\n \"appointments\": {\n \"resourceType\": \"Bundle\",\n \"id\": \"2a020b2f-1577-4ccb-8ee8-1dc09060f727\",\n \"meta\": {\n \"lastUpdated\": \"2024-05-10T03:55:33.823+00:00\"\n },\n \"type\": \"searchset\",\n \"total\": 2,\n \"link\": [\n {\n \"relation\": \"self\",\n \"url\": \"https://inferno-qa.healthit.gov/reference-server/r4/Appointment?patient=pat014\"\n }\n ],\n \"entry\": [\n {\n \"fullUrl\": \"https://inferno-qa.healthit.gov/reference-server/r4/Appointment/125\",\n \"resource\": {\n \"resourceType\": \"Appointment\",\n \"id\": \"125\",\n \"meta\": {\n \"versionId\": \"1\",\n \"lastUpdated\": \"2024-05-10T03:52:09.983+00:00\",\n \"source\": \"#KIaKP1Pfw7EVpLjl\"\n },\n \"status\": \"proposed\",\n \"serviceType\": [\n {\n \"coding\": [\n {\n \"system\": \"http://terminology.hl7.org/CodeSystem/service-type\",\n \"code\": \"183\",\n \"display\": \"Sleep Medicine\"\n }\n ]\n }\n ],\n \"appointmentType\": {\n \"coding\": [\n {\n \"system\": \"http://terminology.hl7.org/CodeSystem/v2-0276\",\n \"code\": \"FOLLOWUP\",\n \"display\": \"A follow up visit from a previous appointment\"\n }\n ]\n },\n \"description\": \"CPAP adjustments\",\n \"start\": \"2019-08-10T09:00:00Z\",\n \"end\": \"2019-08-10T11:00:00Z\",\n \"created\": \"2019-08-01\",\n \"participant\": [\n {\n \"actor\": {\n \"reference\": \"Patient/pat014\",\n \"display\": \"Peter James Chalmers\"\n },\n \"required\": \"required\",\n \"status\": \"tentative\"\n },\n {\n \"actor\": {\n \"reference\": \"Practitioner/pra1255\",\n \"display\": \"Dr Adam Careful\"\n },\n \"required\": \"required\",\n \"status\": \"accepted\"\n }\n ],\n \"requestedPeriod\": [\n {\n \"start\": \"2020-05-23\",\n \"end\": \"2020-05-23\"\n }\n ]\n },\n \"search\": {\n \"mode\": \"match\"\n }\n },\n {\n \"fullUrl\": \"https://inferno-qa.healthit.gov/reference-server/r4/Appointment/126\",\n \"resource\": {\n \"resourceType\": \"Appointment\",\n \"id\": \"126\",\n \"meta\": {\n \"versionId\": \"1\",\n \"lastUpdated\": \"2024-05-10T03:55:17.405+00:00\",\n \"source\": \"#RTJWuip7OTKyTjG7\"\n },\n \"status\": \"proposed\",\n \"appointmentType\": {\n \"coding\": [\n {\n \"system\": \"http://terminology.hl7.org/CodeSystem/v2-0276\",\n \"code\": \"CHECKUP\",\n \"display\": \"A routine check-up, such as an annual physical\"\n }\n ]\n },\n \"description\": \"Regular physical\",\n \"start\": \"2020-08-01T11:00:00Z\",\n \"end\": \"2020-08-01T13:00:00Z\",\n \"created\": \"2019-08-01\",\n \"participant\": [\n {\n \"actor\": {\n \"reference\": \"Patient/pat014\",\n \"display\": \"Peter James Chalmers\"\n },\n \"required\": \"required\",\n \"status\": \"tentative\"\n },\n {\n \"actor\": {\n \"reference\": \"Practitioner/pra1255\",\n \"display\": \"Dr Adam Careful\"\n },\n \"required\": \"required\",\n \"status\": \"accepted\"\n }\n ],\n \"requestedPeriod\": [\n {\n \"start\": \"2021-05-23\",\n \"end\": \"2021-05-23\"\n }\n ]\n },\n \"search\": {\n \"mode\": \"match\"\n }\n }\n ]\n }\n },\n \"prefetch\": {\n \"encounterBundle\": {\n \"resourceType\": \"Bundle\",\n \"id\": \"def4ab7c-3ce3-441a-bed6-49267d0f787a\",\n \"meta\": {\n \"lastUpdated\": \"2024-05-10T03:57:58.842+00:00\"\n },\n \"type\": \"searchset\",\n \"total\": 1,\n \"link\": [\n {\n \"relation\": \"self\",\n \"url\": \"https://inferno-qa.healthit.gov/reference-server/r4/Encounter?_id=enc-pat014-cold&_include=Encounter%3Apatient&_include=Encounter%3Aservice-provider&_include=Encounter%3Apractitioner&_include=Encounter%3Alocation\"\n }\n ],\n \"entry\": [\n {\n \"fullUrl\": \"https://inferno-qa.healthit.gov/reference-server/r4/Encounter/enc-pat014-cold\",\n \"resource\": {\n \"resourceType\": \"Encounter\",\n \"id\": \"enc-pat014-cold\",\n \"meta\": {\n \"versionId\": \"1\",\n \"lastUpdated\": \"2024-04-25T17:48:34.726+00:00\",\n \"source\": \"#2Co42uqYJxUtNNmA\"\n },\n \"status\": \"finished\",\n \"class\": {\n \"system\": \"http://terminology.hl7.org/CodeSystem/v3-ActCode\",\n \"code\": \"AMB\",\n \"display\": \"ambulatory\"\n },\n \"type\": [\n {\n \"coding\": [\n {\n \"system\": \"http://snomed.info/sct\",\n \"code\": \"185345009\",\n \"display\": \"Encounter for symptom\"\n }\n ]\n }\n ],\n \"priority\": {\n \"coding\": [\n {\n \"system\": \"http://snomed.info/sct\",\n \"code\": \"103391001\",\n \"display\": \"Urgent\"\n }\n ]\n },\n \"subject\": {\n \"reference\": \"Patient/pat014\",\n \"display\": \"Teddy\"\n },\n \"participant\": [\n {\n \"individual\": {\n \"reference\": \"Practitioner/pra-sstrange\"\n }\n }\n ],\n \"period\": {\n \"start\": \"2020-02-14T10:40:10+01:00\",\n \"end\": \"2020-02-14T12:40:10+01:00\"\n },\n \"length\": {\n \"value\": 56,\n \"unit\": \"minutes\",\n \"system\": \"http://unitsofmeasure.org\",\n \"code\": \"min\"\n },\n \"reasonCode\": [\n {\n \"coding\": [\n {\n \"system\": \"http://snomed.info/sct\",\n \"code\": \"82272006\",\n \"display\": \"Common cold (disorder)\"\n }\n ]\n }\n ],\n \"diagnosis\": [\n {\n \"condition\": {\n \"reference\": \"Condition/cond014b\",\n \"display\": \"The patient is treated for heartburn\"\n },\n \"use\": {\n \"coding\": [\n {\n \"system\": \"http://terminology.hl7.org/CodeSystem/diagnosis-role\",\n \"code\": \"AD\",\n \"display\": \"Admission diagnosis\"\n }\n ]\n },\n \"rank\": 2\n },\n {\n \"condition\": {\n \"reference\": \"Condition/cond014b\",\n \"display\": \"The patient is treated for heartburn\"\n },\n \"use\": {\n \"coding\": [\n {\n \"system\": \"http://terminology.hl7.org/CodeSystem/diagnosis-role\",\n \"code\": \"CC\",\n \"display\": \"Chief complaint\"\n }\n ]\n },\n \"rank\": 1\n }\n ]\n },\n \"search\": {\n \"mode\": \"match\"\n }\n },\n {\n \"fullUrl\": \"https://inferno-qa.healthit.gov/reference-server/r4/Patient/pat014\",\n \"resource\": {\n \"resourceType\": \"Patient\",\n \"id\": \"pat014\",\n \"meta\": {\n \"versionId\": \"1\",\n \"lastUpdated\": \"2024-04-25T17:48:34.662+00:00\",\n \"source\": \"#4RE6S8FR2Y44APk0\"\n },\n \"text\": {\n \"status\": \"generated\",\n \"div\": \"
Theodor Alan Roosevelt ROOSEVELT
Identifier0M846129001NF
Address7525 Colshire Dr
McLean VA
Date of birth04 July 1946
\"\n },\n \"identifier\": [\n {\n \"use\": \"usual\",\n \"type\": {\n \"coding\": [\n {\n \"system\": \"http://terminology.hl7.org/CodeSystem/v2-0203\",\n \"code\": \"MR\"\n }\n ],\n \"text\": \"Medical Record Number\"\n },\n \"system\": \"http://hl7.org/fhir/sid/us-medicare\",\n \"value\": \"0M846129001NF\"\n }\n ],\n \"name\": [\n {\n \"use\": \"official\",\n \"family\": \"Roosevelt\",\n \"given\": [\n \"Theodor\",\n \"Alan\",\n \"Roosevelt\"\n ]\n }\n ],\n \"gender\": \"male\",\n \"birthDate\": \"1946-07-04\",\n \"address\": [\n {\n \"use\": \"home\",\n \"type\": \"both\",\n \"line\": [\n \"7525 Colshire Dr\"\n ],\n \"city\": \"McLean\",\n \"state\": \"VA\",\n \"postalCode\": \"22102\"\n }\n ]\n },\n \"search\": {\n \"mode\": \"include\"\n }\n },\n {\n \"fullUrl\": \"https://inferno-qa.healthit.gov/reference-server/r4/Practitioner/pra-sstrange\",\n \"resource\": {\n \"resourceType\": \"Practitioner\",\n \"id\": \"pra-sstrange\",\n \"meta\": {\n \"versionId\": \"1\",\n \"lastUpdated\": \"2024-04-25T17:48:33.939+00:00\",\n \"source\": \"#zUqXWSo8DhSDB3un\"\n },\n \"identifier\": [\n {\n \"system\": \"http://hl7.org/fhir/sid/us-npi\",\n \"value\": \"1122334466\"\n }\n ],\n \"name\": [\n {\n \"use\": \"official\",\n \"family\": \"Strange\",\n \"given\": [\n \"Stephen\"\n ],\n \"prefix\": [\n \"Dr.\"\n ]\n }\n ]\n },\n \"search\": {\n \"mode\": \"include\"\n }\n }\n ]\n },\n \"patient\": {\n \"resourceType\": \"Patient\",\n \"id\": \"pat014\",\n \"meta\": {\n \"versionId\": \"1\",\n \"lastUpdated\": \"2024-04-25T17:48:34.662+00:00\",\n \"source\": \"#4RE6S8FR2Y44APk0\"\n },\n \"text\": {\n \"status\": \"generated\",\n \"div\": \"
Theodor Alan Roosevelt ROOSEVELT
Identifier0M846129001NF
Address7525 Colshire Dr
McLean VA
Date of birth04 July 1946
\"\n },\n \"identifier\": [\n {\n \"use\": \"usual\",\n \"type\": {\n \"coding\": [\n {\n \"system\": \"http://terminology.hl7.org/CodeSystem/v2-0203\",\n \"code\": \"MR\"\n }\n ],\n \"text\": \"Medical Record Number\"\n },\n \"system\": \"http://hl7.org/fhir/sid/us-medicare\",\n \"value\": \"0M846129001NF\"\n }\n ],\n \"name\": [\n {\n \"use\": \"official\",\n \"family\": \"Roosevelt\",\n \"given\": [\n \"Theodor\",\n \"Alan\",\n \"Roosevelt\"\n ]\n }\n ],\n \"gender\": \"male\",\n \"birthDate\": \"1946-07-04\",\n \"address\": [\n {\n \"use\": \"home\",\n \"type\": \"both\",\n \"line\": [\n \"7525 Colshire Dr\"\n ],\n \"city\": \"McLean\",\n \"state\": \"VA\",\n \"postalCode\": \"22102\"\n }\n ]\n },\n \"coverageBundle\": {\n \"resourceType\": \"Bundle\",\n \"id\": \"5b0be74e-9f57-4d11-a5e9-ec05fe9af42c\",\n \"meta\": {\n \"lastUpdated\": \"2024-05-10T03:56:46.942+00:00\"\n },\n \"type\": \"searchset\",\n \"total\": 1,\n \"link\": [\n {\n \"relation\": \"self\",\n \"url\": \"https://inferno-qa.healthit.gov/reference-server/r4/Coverage?patient=pat014\"\n }\n ],\n \"entry\": [\n {\n \"fullUrl\": \"https://inferno-qa.healthit.gov/reference-server/r4/Coverage/cov014\",\n \"resource\": {\n \"resourceType\": \"Coverage\",\n \"id\": \"cov014\",\n \"meta\": {\n \"versionId\": \"1\",\n \"lastUpdated\": \"2024-04-25T17:48:34.696+00:00\",\n \"source\": \"#XvndBJotP6fVi3pl\"\n },\n \"status\": \"active\",\n \"subscriberId\": \"10A3D58WH1600\",\n \"beneficiary\": {\n \"reference\": \"Patient/pat014\"\n },\n \"payor\": [\n {\n \"reference\": \"Organization/org1234\"\n }\n ],\n \"class\": [\n {\n \"type\": {\n \"coding\": [\n {\n \"system\": \"http://hl7.org/fhir/coverage-class\",\n \"code\": \"plan\"\n }\n ]\n },\n \"value\": \"Medicare Part B\"\n }\n ]\n },\n \"search\": {\n \"mode\": \"match\"\n }\n }\n ]\n }\n },\n \"extension\": {\n \"davinci-crd.configuration\": {\n \"coverage\": true,\n \"max-cards\": 10\n }\n }\n }\n]\n", + "description": "Provide a list of request bodies send multiple requests. e.g. [json_body_1, json_body_2]", + "title": "Request bodies collection to use to invoke the `appointment-book` hook", + "type": "textarea", + "value": "[\n {\n \"hookInstance\": \"d1577c69-dfbe-44ad-ba6d-3e05e953b2ea\",\n \"fhirServer\": \"https://inferno-qa.healthit.gov/reference-server/r4\",\n \"hook\": \"appointment-book\",\n \"fhirAuthorization\": {\n \"access_token\": \"SAMPLE_TOKEN\",\n \"token_type\": \"Bearer\",\n \"expires_in\": 300,\n \"scope\": \"user/Patient.read user/Observation.read\",\n \"subject\": \"cds-service\"\n },\n \"context\": {\n \"userId\": \"Practitioner/pra1255\",\n \"patientId\": \"pat015\",\n \"encounterId\": \"enc-pat014\",\n \"appointments\": {\n \"resourceType\": \"Bundle\",\n \"id\": \"2f5d7a40-31c1-4daa-9670-e0e7c748e395\",\n \"meta\": {\n \"lastUpdated\": \"2024-05-10T03:26:08.496+00:00\"\n },\n \"type\": \"searchset\",\n \"total\": 1,\n \"link\": [\n {\n \"relation\": \"self\",\n \"url\": \"https://inferno-qa.healthit.gov/reference-server/r4/Appointment?patient=pat015\"\n }\n ],\n \"entry\": [\n {\n \"fullUrl\": \"https://inferno-qa.healthit.gov/reference-server/r4/Appointment/124\",\n \"resource\": {\n \"resourceType\": \"Appointment\",\n \"id\": \"124\",\n \"meta\": {\n \"versionId\": \"1\",\n \"lastUpdated\": \"2024-05-10T03:25:21.327+00:00\",\n \"source\": \"#meQ6e6aw4020GBer\"\n },\n \"text\": {\n \"status\": \"generated\",\n \"div\": \"

Generated Narrative: Appointment

Resource Appointment "example"

status: proposed

serviceCategory: General Practice (Service category#17)

serviceType: General Practice (Service type#pat015)

specialty: General practice (specialty) (SNOMED CT#394814009)

appointmentType: A follow up visit from a previous appointment (appointmentReason#FOLLOWUP)

reasonReference: Condition/cond015a: Heart problem

priority: 5

description: Discussion on the results of your recent MRI

start: Dec 10, 2013, 9:00:00 AM

end: Dec 10, 2013, 11:00:00 AM

created: 2013-10-10

comment: Further expand on the results of the MRI and determine the next actions that may be appropriate.

basedOn: ServiceRequest/servreq-g0180-1

participant

actor: Patient/pat015: Amy Baxter " SHAW"

required: required

status: accepted

participant

type: attender (ParticipationType#ATND)

actor: Practitioner/pra1255: Dr Adam Careful " CAREFUL"

required: required

status: accepted

participant

actor: Location/example: South Wing, second floor "South Wing, second floor"

required: required

status: accepted

requestedPeriod: 2020-11-01 --> 2020-12-15

\"\n },\n \"status\": \"proposed\",\n \"serviceCategory\": [\n {\n \"coding\": [\n {\n \"system\": \"http://terminology.hl7.org/CodeSystem/service-category\",\n \"code\": \"17\",\n \"display\": \"General Practice\"\n }\n ]\n }\n ],\n \"serviceType\": [\n {\n \"coding\": [\n {\n \"system\": \"http://terminology.hl7.org/CodeSystem/service-type\",\n \"code\": \"124\",\n \"display\": \"General Practice\"\n }\n ]\n }\n ],\n \"specialty\": [\n {\n \"coding\": [\n {\n \"system\": \"http://snomed.info/sct\",\n \"code\": \"394814009\",\n \"display\": \"General practice (specialty)\"\n }\n ]\n }\n ],\n \"appointmentType\": {\n \"coding\": [\n {\n \"system\": \"http://terminology.hl7.org/CodeSystem/v2-0276\",\n \"code\": \"FOLLOWUP\",\n \"display\": \"A follow up visit from a previous appointment\"\n }\n ]\n },\n \"reasonReference\": [\n {\n \"reference\": \"Condition/cond015a\",\n \"display\": \"Heart problem\"\n }\n ],\n \"priority\": 5,\n \"description\": \"Discussion on the results of your recent MRI\",\n \"start\": \"2013-12-10T09:00:00Z\",\n \"end\": \"2013-12-10T11:00:00Z\",\n \"created\": \"2013-10-10\",\n \"comment\": \"Further expand on the results of the MRI and determine the next actions that may be appropriate.\",\n \"basedOn\": [\n {\n \"reference\": \"ServiceRequest/servreq-g0180-1\"\n }\n ],\n \"participant\": [\n {\n \"actor\": {\n \"reference\": \"Patient/pat015\",\n \"display\": \"Amy Baxter\"\n },\n \"required\": \"required\",\n \"status\": \"accepted\"\n },\n {\n \"type\": [\n {\n \"coding\": [\n {\n \"system\": \"http://terminology.hl7.org/CodeSystem/v3-ParticipationType\",\n \"code\": \"ATND\"\n }\n ]\n }\n ],\n \"actor\": {\n \"reference\": \"Practitioner/pra1255\",\n \"display\": \"Dr Adam Careful\"\n },\n \"required\": \"required\",\n \"status\": \"accepted\"\n },\n {\n \"actor\": {\n \"reference\": \"Location/loc1234\",\n \"display\": \"South Wing, second floor\"\n },\n \"required\": \"required\",\n \"status\": \"accepted\"\n }\n ],\n \"requestedPeriod\": [\n {\n \"start\": \"2020-11-01\",\n \"end\": \"2020-12-15\"\n }\n ]\n },\n \"search\": {\n \"mode\": \"match\"\n }\n }\n ]\n }\n },\n \"prefetch\": {\n \"encounterBundle\": {\n \"resourceType\": \"Bundle\",\n \"id\": \"8ffa667f-77ce-41b7-a873-da447793a195\",\n \"meta\": {\n \"lastUpdated\": \"2024-05-10T03:33:16.240+00:00\"\n },\n \"type\": \"searchset\",\n \"total\": 1,\n \"link\": [\n {\n \"relation\": \"self\",\n \"url\": \"https://inferno-qa.healthit.gov/reference-server/r4/Encounter?_id=enc-pat014&_include=Encounter%3Apatient&_include=Encounter%3Aservice-provider&_include=Encounter%3Apractitioner&_include=Encounter%3Alocation\"\n }\n ],\n \"entry\": [\n {\n \"fullUrl\": \"https://inferno-qa.healthit.gov/reference-server/r4/Encounter/enc-pat014\",\n \"resource\": {\n \"resourceType\": \"Encounter\",\n \"id\": \"enc-pat014\",\n \"meta\": {\n \"versionId\": \"1\",\n \"lastUpdated\": \"2024-04-25T17:48:34.732+00:00\",\n \"source\": \"#RVwRUek6bQC44Wa9\"\n },\n \"status\": \"finished\",\n \"class\": {\n \"system\": \"http://terminology.hl7.org/CodeSystem/v3-ActCode\",\n \"code\": \"AMB\",\n \"display\": \"ambulatory\"\n },\n \"type\": [\n {\n \"coding\": [\n {\n \"system\": \"http://snomed.info/sct\",\n \"code\": \"185349003\",\n \"display\": \"Encounter for check up\"\n }\n ]\n }\n ],\n \"priority\": {\n \"coding\": [\n {\n \"system\": \"http://snomed.info/sct\",\n \"code\": \"103391001\",\n \"display\": \"Urgent\"\n }\n ]\n },\n \"subject\": {\n \"reference\": \"Patient/pat014\",\n \"display\": \"Teddy\"\n },\n \"participant\": [\n {\n \"individual\": {\n \"reference\": \"Practitioner/pra1234\"\n }\n }\n ],\n \"period\": {\n \"start\": \"2020-01-15T12:40:10+01:00\",\n \"end\": \"2020-01-15T13:40:10+01:00\"\n },\n \"length\": {\n \"value\": 56,\n \"unit\": \"minutes\",\n \"system\": \"http://unitsofmeasure.org\",\n \"code\": \"min\"\n },\n \"reasonCode\": [\n {\n \"coding\": [\n {\n \"system\": \"http://snomed.info/sct\",\n \"code\": \"42343007\",\n \"display\": \"Congestive heart failure (disorder)\"\n }\n ]\n }\n ],\n \"diagnosis\": [\n {\n \"condition\": {\n \"reference\": \"Condition/cond014a\",\n \"display\": \"Complications from infection on January 9th, 2020\"\n },\n \"use\": {\n \"coding\": [\n {\n \"system\": \"http://terminology.hl7.org/CodeSystem/diagnosis-role\",\n \"code\": \"AD\",\n \"display\": \"Admission diagnosis\"\n }\n ]\n },\n \"rank\": 2\n },\n {\n \"condition\": {\n \"reference\": \"Condition/cond014a\",\n \"display\": \"The patient is treated for wheezing\"\n },\n \"use\": {\n \"coding\": [\n {\n \"system\": \"http://terminology.hl7.org/CodeSystem/diagnosis-role\",\n \"code\": \"CC\",\n \"display\": \"Chief complaint\"\n }\n ]\n },\n \"rank\": 1\n }\n ]\n },\n \"search\": {\n \"mode\": \"match\"\n }\n },\n {\n \"fullUrl\": \"https://inferno-qa.healthit.gov/reference-server/r4/Practitioner/pra1234\",\n \"resource\": {\n \"resourceType\": \"Practitioner\",\n \"id\": \"pra1234\",\n \"meta\": {\n \"versionId\": \"1\",\n \"lastUpdated\": \"2024-04-25T17:48:33.625+00:00\",\n \"source\": \"#3fLOP5tSuZrGDiae\",\n \"profile\": [\n \"http://hl7.org/fhir/us/core/StructureDefinition/us-core-practitioner\"\n ]\n },\n \"identifier\": [\n {\n \"system\": \"http://hl7.org/fhir/sid/us-npi\",\n \"value\": \"1122334455\"\n }\n ],\n \"name\": [\n {\n \"use\": \"official\",\n \"family\": \"Doe\",\n \"given\": [\n \"Jane\",\n \"Betty\"\n ],\n \"prefix\": [\n \"Dr.\"\n ]\n }\n ],\n \"telecom\": [\n {\n \"system\": \"phone\",\n \"value\": \"716-873-1557\"\n },\n {\n \"system\": \"email\",\n \"value\": \"jane.betty@myhospital.com\"\n }\n ],\n \"address\": [\n {\n \"use\": \"home\",\n \"type\": \"both\",\n \"line\": [\n \"840 Seneca St\"\n ],\n \"city\": \"Buffalo\",\n \"state\": \"NY\",\n \"postalCode\": \"14210\"\n }\n ],\n \"qualification\": [\n {\n \"identifier\": [\n {\n \"system\": \"http://example.org/UniversityIdentifier\",\n \"value\": \"12345\"\n }\n ],\n \"code\": {\n \"coding\": [\n {\n \"system\": \"http://terminology.hl7.org/CodeSystem/v2-0360/2.7\",\n \"code\": \"MD\",\n \"display\": \"Doctor of Medicine\"\n }\n ],\n \"text\": \"Doctor of Medicine\"\n },\n \"period\": {\n \"start\": \"1995\"\n },\n \"issuer\": {\n \"display\": \"Example University\"\n }\n }\n ]\n },\n \"search\": {\n \"mode\": \"include\"\n }\n },\n {\n \"fullUrl\": \"https://inferno-qa.healthit.gov/reference-server/r4/Patient/pat014\",\n \"resource\": {\n \"resourceType\": \"Patient\",\n \"id\": \"pat014\",\n \"meta\": {\n \"versionId\": \"1\",\n \"lastUpdated\": \"2024-04-25T17:48:34.662+00:00\",\n \"source\": \"#4RE6S8FR2Y44APk0\"\n },\n \"text\": {\n \"status\": \"generated\",\n \"div\": \"
Theodor Alan Roosevelt ROOSEVELT
Identifier0M846129001NF
Address7525 Colshire Dr
McLean VA
Date of birth04 July 1946
\"\n },\n \"identifier\": [\n {\n \"use\": \"usual\",\n \"type\": {\n \"coding\": [\n {\n \"system\": \"http://terminology.hl7.org/CodeSystem/v2-0203\",\n \"code\": \"MR\"\n }\n ],\n \"text\": \"Medical Record Number\"\n },\n \"system\": \"http://hl7.org/fhir/sid/us-medicare\",\n \"value\": \"0M846129001NF\"\n }\n ],\n \"name\": [\n {\n \"use\": \"official\",\n \"family\": \"Roosevelt\",\n \"given\": [\n \"Theodor\",\n \"Alan\",\n \"Roosevelt\"\n ]\n }\n ],\n \"gender\": \"male\",\n \"birthDate\": \"1946-07-04\",\n \"address\": [\n {\n \"use\": \"home\",\n \"type\": \"both\",\n \"line\": [\n \"7525 Colshire Dr\"\n ],\n \"city\": \"McLean\",\n \"state\": \"VA\",\n \"postalCode\": \"22102\"\n }\n ]\n },\n \"search\": {\n \"mode\": \"include\"\n }\n }\n ]\n },\n \"patient\": {\n \"resourceType\": \"Patient\",\n \"id\": \"pat015\",\n \"meta\": {\n \"versionId\": \"1\",\n \"lastUpdated\": \"2024-04-25T17:48:34.068+00:00\",\n \"source\": \"#fT6fENtjF2A8Ne61\"\n },\n \"text\": {\n \"status\": \"generated\",\n \"div\": \"
William Hale Oster OSTER
Identifier0M34355006FW
Address202 Burlington Road
Bedford MA
Date of birth23 February 2015
\"\n },\n \"identifier\": [\n {\n \"use\": \"usual\",\n \"type\": {\n \"coding\": [\n {\n \"system\": \"http://terminology.hl7.org/CodeSystem/v2-0203\",\n \"code\": \"MR\"\n }\n ],\n \"text\": \"Medical Record Number\"\n },\n \"system\": \"http://hl7.org/fhir/sid/us-medicare\",\n \"value\": \"0M34355006FW\"\n }\n ],\n \"name\": [\n {\n \"use\": \"official\",\n \"family\": \"Oster\",\n \"given\": [\n \"William\",\n \"Hale\",\n \"Oster\"\n ]\n }\n ],\n \"telecom\": [\n {\n \"system\": \"phone\",\n \"value\": \"(781) 555-5555\",\n \"use\": \"home\",\n \"rank\": 1\n },\n {\n \"system\": \"phone\",\n \"value\": \"(781) 555 5613\",\n \"use\": \"work\",\n \"rank\": 2\n },\n {\n \"system\": \"phone\",\n \"value\": \"(781) 555 8834\",\n \"use\": \"old\",\n \"period\": {\n \"end\": \"2014\"\n }\n }\n ],\n \"gender\": \"male\",\n \"birthDate\": \"2015-02-23\",\n \"address\": [\n {\n \"use\": \"home\",\n \"type\": \"both\",\n \"line\": [\n \"202 Burlington Road\"\n ],\n \"city\": \"Bedford\",\n \"state\": \"MA\",\n \"postalCode\": \"01730\"\n }\n ]\n },\n \"coverageBundle\": {\n \"resourceType\": \"Bundle\",\n \"type\": \"collection\",\n \"entry\": [\n {\n \"fullUrl\": \"https://inferno-qa.healthit.gov/reference-server/r4/Coverage/cov015\",\n \"resource\": {\n \"resourceType\": \"Coverage\",\n \"id\": \"cov015\",\n \"meta\": {\n \"versionId\": \"1\",\n \"lastUpdated\": \"2024-05-08T09:47:16.618-04:00\",\n \"source\": \"#2oewiwPOJlURF4aM\"\n },\n \"status\": \"active\",\n \"subscriberId\": \"10A3D58WH456\",\n \"beneficiary\": {\n \"reference\": \"Patient/pat015\"\n },\n \"payor\": [\n {\n \"reference\": \"Organization/org1234\"\n }\n ],\n \"class\": [\n {\n \"type\": {\n \"coding\": [\n {\n \"system\": \"http://hl7.org/fhir/coverage-class\",\n \"code\": \"plan\"\n }\n ]\n },\n \"value\": \"Medicare Part A\"\n }\n ]\n },\n \"search\": {\n \"mode\": \"match\"\n }\n }\n ]\n }\n },\n \"extension\": {\n \"davinci-crd.configuration\": {\n \"coverage\": true,\n \"max-cards\": 10\n }\n }\n },\n {\n \"hookInstance\": \"d1577c69-dfbe-44ad-ba6d-3e05e953b2ea\",\n \"fhirServer\": \"https://inferno-qa.healthit.gov/reference-server/r4\",\n \"hook\": \"appointment-book\",\n \"fhirAuthorization\": {\n \"access_token\": \"SAMPLE_TOKEN\",\n \"token_type\": \"Bearer\",\n \"expires_in\": 300,\n \"scope\": \"user/Patient.read user/Observation.read\",\n \"subject\": \"cds-service\"\n },\n \"context\": {\n \"userId\": \"Practitioner/pra1255\",\n \"patientId\": \"pat014\",\n \"encounterId\": \"enc-pat014-cold\",\n \"appointments\": {\n \"resourceType\": \"Bundle\",\n \"id\": \"2a020b2f-1577-4ccb-8ee8-1dc09060f727\",\n \"meta\": {\n \"lastUpdated\": \"2024-05-10T03:55:33.823+00:00\"\n },\n \"type\": \"searchset\",\n \"total\": 2,\n \"link\": [\n {\n \"relation\": \"self\",\n \"url\": \"https://inferno-qa.healthit.gov/reference-server/r4/Appointment?patient=pat014\"\n }\n ],\n \"entry\": [\n {\n \"fullUrl\": \"https://inferno-qa.healthit.gov/reference-server/r4/Appointment/125\",\n \"resource\": {\n \"resourceType\": \"Appointment\",\n \"id\": \"125\",\n \"meta\": {\n \"versionId\": \"1\",\n \"lastUpdated\": \"2024-05-10T03:52:09.983+00:00\",\n \"source\": \"#KIaKP1Pfw7EVpLjl\"\n },\n \"status\": \"proposed\",\n \"serviceType\": [\n {\n \"coding\": [\n {\n \"system\": \"http://terminology.hl7.org/CodeSystem/service-type\",\n \"code\": \"183\",\n \"display\": \"Sleep Medicine\"\n }\n ]\n }\n ],\n \"appointmentType\": {\n \"coding\": [\n {\n \"system\": \"http://terminology.hl7.org/CodeSystem/v2-0276\",\n \"code\": \"FOLLOWUP\",\n \"display\": \"A follow up visit from a previous appointment\"\n }\n ]\n },\n \"description\": \"CPAP adjustments\",\n \"start\": \"2019-08-10T09:00:00Z\",\n \"end\": \"2019-08-10T11:00:00Z\",\n \"created\": \"2019-08-01\",\n \"participant\": [\n {\n \"actor\": {\n \"reference\": \"Patient/pat014\",\n \"display\": \"Peter James Chalmers\"\n },\n \"required\": \"required\",\n \"status\": \"tentative\"\n },\n {\n \"actor\": {\n \"reference\": \"Practitioner/pra1255\",\n \"display\": \"Dr Adam Careful\"\n },\n \"required\": \"required\",\n \"status\": \"accepted\"\n }\n ],\n \"requestedPeriod\": [\n {\n \"start\": \"2020-05-23\",\n \"end\": \"2020-05-23\"\n }\n ]\n },\n \"search\": {\n \"mode\": \"match\"\n }\n },\n {\n \"fullUrl\": \"https://inferno-qa.healthit.gov/reference-server/r4/Appointment/126\",\n \"resource\": {\n \"resourceType\": \"Appointment\",\n \"id\": \"126\",\n \"meta\": {\n \"versionId\": \"1\",\n \"lastUpdated\": \"2024-05-10T03:55:17.405+00:00\",\n \"source\": \"#RTJWuip7OTKyTjG7\"\n },\n \"status\": \"proposed\",\n \"appointmentType\": {\n \"coding\": [\n {\n \"system\": \"http://terminology.hl7.org/CodeSystem/v2-0276\",\n \"code\": \"CHECKUP\",\n \"display\": \"A routine check-up, such as an annual physical\"\n }\n ]\n },\n \"description\": \"Regular physical\",\n \"start\": \"2020-08-01T11:00:00Z\",\n \"end\": \"2020-08-01T13:00:00Z\",\n \"created\": \"2019-08-01\",\n \"participant\": [\n {\n \"actor\": {\n \"reference\": \"Patient/pat014\",\n \"display\": \"Peter James Chalmers\"\n },\n \"required\": \"required\",\n \"status\": \"tentative\"\n },\n {\n \"actor\": {\n \"reference\": \"Practitioner/pra1255\",\n \"display\": \"Dr Adam Careful\"\n },\n \"required\": \"required\",\n \"status\": \"accepted\"\n }\n ],\n \"requestedPeriod\": [\n {\n \"start\": \"2021-05-23\",\n \"end\": \"2021-05-23\"\n }\n ]\n },\n \"search\": {\n \"mode\": \"match\"\n }\n }\n ]\n }\n },\n \"prefetch\": {\n \"encounterBundle\": {\n \"resourceType\": \"Bundle\",\n \"id\": \"def4ab7c-3ce3-441a-bed6-49267d0f787a\",\n \"meta\": {\n \"lastUpdated\": \"2024-05-10T03:57:58.842+00:00\"\n },\n \"type\": \"searchset\",\n \"total\": 1,\n \"link\": [\n {\n \"relation\": \"self\",\n \"url\": \"https://inferno-qa.healthit.gov/reference-server/r4/Encounter?_id=enc-pat014-cold&_include=Encounter%3Apatient&_include=Encounter%3Aservice-provider&_include=Encounter%3Apractitioner&_include=Encounter%3Alocation\"\n }\n ],\n \"entry\": [\n {\n \"fullUrl\": \"https://inferno-qa.healthit.gov/reference-server/r4/Encounter/enc-pat014-cold\",\n \"resource\": {\n \"resourceType\": \"Encounter\",\n \"id\": \"enc-pat014-cold\",\n \"meta\": {\n \"versionId\": \"1\",\n \"lastUpdated\": \"2024-04-25T17:48:34.726+00:00\",\n \"source\": \"#2Co42uqYJxUtNNmA\"\n },\n \"status\": \"finished\",\n \"class\": {\n \"system\": \"http://terminology.hl7.org/CodeSystem/v3-ActCode\",\n \"code\": \"AMB\",\n \"display\": \"ambulatory\"\n },\n \"type\": [\n {\n \"coding\": [\n {\n \"system\": \"http://snomed.info/sct\",\n \"code\": \"185345009\",\n \"display\": \"Encounter for symptom\"\n }\n ]\n }\n ],\n \"priority\": {\n \"coding\": [\n {\n \"system\": \"http://snomed.info/sct\",\n \"code\": \"103391001\",\n \"display\": \"Urgent\"\n }\n ]\n },\n \"subject\": {\n \"reference\": \"Patient/pat014\",\n \"display\": \"Teddy\"\n },\n \"participant\": [\n {\n \"individual\": {\n \"reference\": \"Practitioner/pra-sstrange\"\n }\n }\n ],\n \"period\": {\n \"start\": \"2020-02-14T10:40:10+01:00\",\n \"end\": \"2020-02-14T12:40:10+01:00\"\n },\n \"length\": {\n \"value\": 56,\n \"unit\": \"minutes\",\n \"system\": \"http://unitsofmeasure.org\",\n \"code\": \"min\"\n },\n \"reasonCode\": [\n {\n \"coding\": [\n {\n \"system\": \"http://snomed.info/sct\",\n \"code\": \"82272006\",\n \"display\": \"Common cold (disorder)\"\n }\n ]\n }\n ],\n \"diagnosis\": [\n {\n \"condition\": {\n \"reference\": \"Condition/cond014b\",\n \"display\": \"The patient is treated for heartburn\"\n },\n \"use\": {\n \"coding\": [\n {\n \"system\": \"http://terminology.hl7.org/CodeSystem/diagnosis-role\",\n \"code\": \"AD\",\n \"display\": \"Admission diagnosis\"\n }\n ]\n },\n \"rank\": 2\n },\n {\n \"condition\": {\n \"reference\": \"Condition/cond014b\",\n \"display\": \"The patient is treated for heartburn\"\n },\n \"use\": {\n \"coding\": [\n {\n \"system\": \"http://terminology.hl7.org/CodeSystem/diagnosis-role\",\n \"code\": \"CC\",\n \"display\": \"Chief complaint\"\n }\n ]\n },\n \"rank\": 1\n }\n ]\n },\n \"search\": {\n \"mode\": \"match\"\n }\n },\n {\n \"fullUrl\": \"https://inferno-qa.healthit.gov/reference-server/r4/Patient/pat014\",\n \"resource\": {\n \"resourceType\": \"Patient\",\n \"id\": \"pat014\",\n \"meta\": {\n \"versionId\": \"1\",\n \"lastUpdated\": \"2024-04-25T17:48:34.662+00:00\",\n \"source\": \"#4RE6S8FR2Y44APk0\"\n },\n \"text\": {\n \"status\": \"generated\",\n \"div\": \"
Theodor Alan Roosevelt ROOSEVELT
Identifier0M846129001NF
Address7525 Colshire Dr
McLean VA
Date of birth04 July 1946
\"\n },\n \"identifier\": [\n {\n \"use\": \"usual\",\n \"type\": {\n \"coding\": [\n {\n \"system\": \"http://terminology.hl7.org/CodeSystem/v2-0203\",\n \"code\": \"MR\"\n }\n ],\n \"text\": \"Medical Record Number\"\n },\n \"system\": \"http://hl7.org/fhir/sid/us-medicare\",\n \"value\": \"0M846129001NF\"\n }\n ],\n \"name\": [\n {\n \"use\": \"official\",\n \"family\": \"Roosevelt\",\n \"given\": [\n \"Theodor\",\n \"Alan\",\n \"Roosevelt\"\n ]\n }\n ],\n \"gender\": \"male\",\n \"birthDate\": \"1946-07-04\",\n \"address\": [\n {\n \"use\": \"home\",\n \"type\": \"both\",\n \"line\": [\n \"7525 Colshire Dr\"\n ],\n \"city\": \"McLean\",\n \"state\": \"VA\",\n \"postalCode\": \"22102\"\n }\n ]\n },\n \"search\": {\n \"mode\": \"include\"\n }\n },\n {\n \"fullUrl\": \"https://inferno-qa.healthit.gov/reference-server/r4/Practitioner/pra-sstrange\",\n \"resource\": {\n \"resourceType\": \"Practitioner\",\n \"id\": \"pra-sstrange\",\n \"meta\": {\n \"versionId\": \"1\",\n \"lastUpdated\": \"2024-04-25T17:48:33.939+00:00\",\n \"source\": \"#zUqXWSo8DhSDB3un\"\n },\n \"identifier\": [\n {\n \"system\": \"http://hl7.org/fhir/sid/us-npi\",\n \"value\": \"1122334466\"\n }\n ],\n \"name\": [\n {\n \"use\": \"official\",\n \"family\": \"Strange\",\n \"given\": [\n \"Stephen\"\n ],\n \"prefix\": [\n \"Dr.\"\n ]\n }\n ]\n },\n \"search\": {\n \"mode\": \"include\"\n }\n }\n ]\n },\n \"patient\": {\n \"resourceType\": \"Patient\",\n \"id\": \"pat014\",\n \"meta\": {\n \"versionId\": \"1\",\n \"lastUpdated\": \"2024-04-25T17:48:34.662+00:00\",\n \"source\": \"#4RE6S8FR2Y44APk0\"\n },\n \"text\": {\n \"status\": \"generated\",\n \"div\": \"
Theodor Alan Roosevelt ROOSEVELT
Identifier0M846129001NF
Address7525 Colshire Dr
McLean VA
Date of birth04 July 1946
\"\n },\n \"identifier\": [\n {\n \"use\": \"usual\",\n \"type\": {\n \"coding\": [\n {\n \"system\": \"http://terminology.hl7.org/CodeSystem/v2-0203\",\n \"code\": \"MR\"\n }\n ],\n \"text\": \"Medical Record Number\"\n },\n \"system\": \"http://hl7.org/fhir/sid/us-medicare\",\n \"value\": \"0M846129001NF\"\n }\n ],\n \"name\": [\n {\n \"use\": \"official\",\n \"family\": \"Roosevelt\",\n \"given\": [\n \"Theodor\",\n \"Alan\",\n \"Roosevelt\"\n ]\n }\n ],\n \"gender\": \"male\",\n \"birthDate\": \"1946-07-04\",\n \"address\": [\n {\n \"use\": \"home\",\n \"type\": \"both\",\n \"line\": [\n \"7525 Colshire Dr\"\n ],\n \"city\": \"McLean\",\n \"state\": \"VA\",\n \"postalCode\": \"22102\"\n }\n ]\n },\n \"coverageBundle\": {\n \"resourceType\": \"Bundle\",\n \"id\": \"5b0be74e-9f57-4d11-a5e9-ec05fe9af42c\",\n \"meta\": {\n \"lastUpdated\": \"2024-05-10T03:56:46.942+00:00\"\n },\n \"type\": \"searchset\",\n \"total\": 1,\n \"link\": [\n {\n \"relation\": \"self\",\n \"url\": \"https://inferno-qa.healthit.gov/reference-server/r4/Coverage?patient=pat014\"\n }\n ],\n \"entry\": [\n {\n \"fullUrl\": \"https://inferno-qa.healthit.gov/reference-server/r4/Coverage/cov014\",\n \"resource\": {\n \"resourceType\": \"Coverage\",\n \"id\": \"cov014\",\n \"meta\": {\n \"versionId\": \"1\",\n \"lastUpdated\": \"2024-04-25T17:48:34.696+00:00\",\n \"source\": \"#XvndBJotP6fVi3pl\"\n },\n \"status\": \"active\",\n \"subscriberId\": \"10A3D58WH1600\",\n \"beneficiary\": {\n \"reference\": \"Patient/pat014\"\n },\n \"payor\": [\n {\n \"reference\": \"Organization/org1234\"\n }\n ],\n \"class\": [\n {\n \"type\": {\n \"coding\": [\n {\n \"system\": \"http://hl7.org/fhir/coverage-class\",\n \"code\": \"plan\"\n }\n ]\n },\n \"value\": \"Medicare Part B\"\n }\n ]\n },\n \"search\": {\n \"mode\": \"match\"\n }\n }\n ]\n }\n },\n \"extension\": {\n \"davinci-crd.configuration\": {\n \"coverage\": true,\n \"max-cards\": 10\n }\n }\n }\n]\n" + }, + { + "name": "encounter_start_request_bodies", + "default": "[\n {\n \"hookInstance\": \"f3945c69-dfbe-44vf-ba6d-3e05e953b2ea\",\n \"fhirServer\": \"https://inferno-qa.healthit.gov/reference-server/r4\",\n \"hook\": \"encounter-start\",\n \"fhirAuthorization\": {\n \"access_token\": \"SAMPLE_TOKEN\",\n \"token_type\": \"Bearer\",\n \"expires_in\": 300,\n \"scope\": \"user/Patient.read user/Practitioner.read user/Encounter.read\",\n \"subject\": \"cds-service\"\n },\n \"context\": {\n \"userId\": \"PractitionerRole/A2340113\",\n \"patientId\": \"1288992\",\n \"encounterId\": \"456\"\n },\n \"prefetch\": {\n \"encounter\": {\n \"resourceType\": \"Encounter\",\n \"id\": \"456\",\n \"identifier\": [\n {\n \"use\": \"official\",\n \"system\": \"http://www.amc.nl/zorgportal/identifiers/visits\",\n \"value\": \"v1451\"\n }\n ],\n \"status\": \"in-progress\",\n \"class\": {\n \"system\": \"http://terminology.hl7.org/CodeSystem/v3-ActCode\",\n \"code\": \"AMB\",\n \"display\": \"ambulatory\"\n },\n \"type\": [\n {\n \"coding\": [\n {\n \"system\": \"http://snomed.info/sct\",\n \"code\": \"270427003\",\n \"display\": \"Patient-initiated encounter\"\n }\n ]\n }\n ],\n \"priority\": {\n \"coding\": [\n {\n \"system\": \"http://snomed.info/sct\",\n \"code\": \"310361003\",\n \"display\": \"Non-urgent cardiological admission\"\n }\n ]\n },\n \"subject\": {\n \"reference\": \"Patient/1288992\"\n },\n \"participant\": [\n {\n \"individual\": {\n \"reference\": \"Practitioner/1234\"\n }\n }\n ],\n \"length\": {\n \"value\": 140,\n \"unit\": \"min\",\n \"system\": \"http://unitsofmeasure.org\",\n \"code\": \"min\"\n },\n \"reasonCode\": [\n {\n \"coding\": [\n {\n \"system\": \"http://snomed.info/sct\",\n \"code\": \"34068001\",\n \"display\": \"Heart valve replacement\"\n }\n ]\n }\n ],\n \"hospitalization\": {\n \"preAdmissionIdentifier\": {\n \"use\": \"official\",\n \"system\": \"http://www.amc.nl/zorgportal/identifiers/pre-admissions\",\n \"value\": \"93042\"\n },\n \"admitSource\": {\n \"coding\": [\n {\n \"system\": \"http://snomed.info/sct\",\n \"code\": \"305956004\",\n \"display\": \"Referral by physician\"\n }\n ]\n },\n \"dischargeDisposition\": {\n \"coding\": [\n {\n \"system\": \"http://snomed.info/sct\",\n \"code\": \"306689006\",\n \"display\": \"Discharge to home\"\n }\n ]\n }\n },\n \"serviceProvider\": {\n \"reference\": \"Organization/org123\",\n \"display\": \"University Medical Center\"\n }\n }\n }\n },\n {\n \"hookInstance\": \"f3945c70-dfbe-44vf-ba6d-3e05e953b2ea\",\n \"fhirServer\": \"https://inferno-qa.healthit.gov/reference-server/r4\",\n \"hook\": \"encounter-start\",\n \"fhirAuthorization\": {\n \"access_token\": \"SAMPLE_TOKEN\",\n \"token_type\": \"Bearer\",\n \"expires_in\": 300,\n \"scope\": \"user/Patient.read user/Practitioner.read user/Encounter.read\",\n \"subject\": \"cds-service\"\n },\n \"context\": {\n \"userId\": \"PractitionerRole/A2340113\",\n \"patientId\": \"1288992\",\n \"encounterId\": \"457\"\n },\n \"prefetch\": {\n \"encounter\": {\n \"resourceType\": \"Encounter\",\n \"id\": \"457\",\n \"identifier\": [\n {\n \"use\": \"official\",\n \"system\": \"http://www.amc.nl/zorgportal/identifiers/visits\",\n \"value\": \"v1451\"\n }\n ],\n \"status\": \"planned\",\n \"class\": {\n \"system\": \"http://terminology.hl7.org/CodeSystem/v3-ActCode\",\n \"code\": \"AMB\",\n \"display\": \"ambulatory\"\n },\n \"type\": [\n {\n \"coding\": [\n {\n \"system\": \"http://snomed.info/sct\",\n \"code\": \"270427003\",\n \"display\": \"Patient-initiated encounter\"\n }\n ]\n }\n ],\n \"priority\": {\n \"coding\": [\n {\n \"system\": \"http://snomed.info/sct\",\n \"code\": \"310361003\",\n \"display\": \"Non-urgent cardiological admission\"\n }\n ]\n },\n \"subject\": {\n \"reference\": \"Patient/1288992\"\n },\n \"participant\": [\n {\n \"individual\": {\n \"reference\": \"Practitioner/1234\"\n }\n }\n ],\n \"length\": {\n \"value\": 140,\n \"unit\": \"min\",\n \"system\": \"http://unitsofmeasure.org\",\n \"code\": \"min\"\n },\n \"reasonCode\": [\n {\n \"coding\": [\n {\n \"system\": \"http://snomed.info/sct\",\n \"code\": \"34068001\",\n \"display\": \"Heart valve replacement\"\n }\n ]\n }\n ],\n \"hospitalization\": {\n \"preAdmissionIdentifier\": {\n \"use\": \"official\",\n \"system\": \"http://www.amc.nl/zorgportal/identifiers/pre-admissions\",\n \"value\": \"93042\"\n },\n \"admitSource\": {\n \"coding\": [\n {\n \"system\": \"http://snomed.info/sct\",\n \"code\": \"305956004\",\n \"display\": \"Referral by physician\"\n }\n ]\n },\n \"dischargeDisposition\": {\n \"coding\": [\n {\n \"system\": \"http://snomed.info/sct\",\n \"code\": \"306689006\",\n \"display\": \"Discharge to home\"\n }\n ]\n }\n },\n \"serviceProvider\": {\n \"reference\": \"Organization/org123\",\n \"display\": \"University Medical Center\"\n }\n }\n }\n }\n]\n", + "description": "Provide a list of request bodies send multiple requests. e.g. [json_body_1, json_body_2]", + "title": "Request bodies collection to use to invoke the `encounter-start` hook", + "type": "textarea", + "value": "[\n {\n \"hookInstance\": \"f3945c69-dfbe-44vf-ba6d-3e05e953b2ea\",\n \"fhirServer\": \"https://inferno-qa.healthit.gov/reference-server/r4\",\n \"hook\": \"encounter-start\",\n \"fhirAuthorization\": {\n \"access_token\": \"SAMPLE_TOKEN\",\n \"token_type\": \"Bearer\",\n \"expires_in\": 300,\n \"scope\": \"user/Patient.read user/Practitioner.read user/Encounter.read\",\n \"subject\": \"cds-service\"\n },\n \"context\": {\n \"userId\": \"PractitionerRole/A2340113\",\n \"patientId\": \"1288992\",\n \"encounterId\": \"456\"\n },\n \"prefetch\": {\n \"encounter\": {\n \"resourceType\": \"Encounter\",\n \"id\": \"456\",\n \"identifier\": [\n {\n \"use\": \"official\",\n \"system\": \"http://www.amc.nl/zorgportal/identifiers/visits\",\n \"value\": \"v1451\"\n }\n ],\n \"status\": \"in-progress\",\n \"class\": {\n \"system\": \"http://terminology.hl7.org/CodeSystem/v3-ActCode\",\n \"code\": \"AMB\",\n \"display\": \"ambulatory\"\n },\n \"type\": [\n {\n \"coding\": [\n {\n \"system\": \"http://snomed.info/sct\",\n \"code\": \"270427003\",\n \"display\": \"Patient-initiated encounter\"\n }\n ]\n }\n ],\n \"priority\": {\n \"coding\": [\n {\n \"system\": \"http://snomed.info/sct\",\n \"code\": \"310361003\",\n \"display\": \"Non-urgent cardiological admission\"\n }\n ]\n },\n \"subject\": {\n \"reference\": \"Patient/1288992\"\n },\n \"participant\": [\n {\n \"individual\": {\n \"reference\": \"Practitioner/1234\"\n }\n }\n ],\n \"length\": {\n \"value\": 140,\n \"unit\": \"min\",\n \"system\": \"http://unitsofmeasure.org\",\n \"code\": \"min\"\n },\n \"reasonCode\": [\n {\n \"coding\": [\n {\n \"system\": \"http://snomed.info/sct\",\n \"code\": \"34068001\",\n \"display\": \"Heart valve replacement\"\n }\n ]\n }\n ],\n \"hospitalization\": {\n \"preAdmissionIdentifier\": {\n \"use\": \"official\",\n \"system\": \"http://www.amc.nl/zorgportal/identifiers/pre-admissions\",\n \"value\": \"93042\"\n },\n \"admitSource\": {\n \"coding\": [\n {\n \"system\": \"http://snomed.info/sct\",\n \"code\": \"305956004\",\n \"display\": \"Referral by physician\"\n }\n ]\n },\n \"dischargeDisposition\": {\n \"coding\": [\n {\n \"system\": \"http://snomed.info/sct\",\n \"code\": \"306689006\",\n \"display\": \"Discharge to home\"\n }\n ]\n }\n },\n \"serviceProvider\": {\n \"reference\": \"Organization/org123\",\n \"display\": \"University Medical Center\"\n }\n }\n }\n },\n {\n \"hookInstance\": \"f3945c70-dfbe-44vf-ba6d-3e05e953b2ea\",\n \"fhirServer\": \"https://inferno-qa.healthit.gov/reference-server/r4\",\n \"hook\": \"encounter-start\",\n \"fhirAuthorization\": {\n \"access_token\": \"SAMPLE_TOKEN\",\n \"token_type\": \"Bearer\",\n \"expires_in\": 300,\n \"scope\": \"user/Patient.read user/Practitioner.read user/Encounter.read\",\n \"subject\": \"cds-service\"\n },\n \"context\": {\n \"userId\": \"PractitionerRole/A2340113\",\n \"patientId\": \"1288992\",\n \"encounterId\": \"457\"\n },\n \"prefetch\": {\n \"encounter\": {\n \"resourceType\": \"Encounter\",\n \"id\": \"457\",\n \"identifier\": [\n {\n \"use\": \"official\",\n \"system\": \"http://www.amc.nl/zorgportal/identifiers/visits\",\n \"value\": \"v1451\"\n }\n ],\n \"status\": \"planned\",\n \"class\": {\n \"system\": \"http://terminology.hl7.org/CodeSystem/v3-ActCode\",\n \"code\": \"AMB\",\n \"display\": \"ambulatory\"\n },\n \"type\": [\n {\n \"coding\": [\n {\n \"system\": \"http://snomed.info/sct\",\n \"code\": \"270427003\",\n \"display\": \"Patient-initiated encounter\"\n }\n ]\n }\n ],\n \"priority\": {\n \"coding\": [\n {\n \"system\": \"http://snomed.info/sct\",\n \"code\": \"310361003\",\n \"display\": \"Non-urgent cardiological admission\"\n }\n ]\n },\n \"subject\": {\n \"reference\": \"Patient/1288992\"\n },\n \"participant\": [\n {\n \"individual\": {\n \"reference\": \"Practitioner/1234\"\n }\n }\n ],\n \"length\": {\n \"value\": 140,\n \"unit\": \"min\",\n \"system\": \"http://unitsofmeasure.org\",\n \"code\": \"min\"\n },\n \"reasonCode\": [\n {\n \"coding\": [\n {\n \"system\": \"http://snomed.info/sct\",\n \"code\": \"34068001\",\n \"display\": \"Heart valve replacement\"\n }\n ]\n }\n ],\n \"hospitalization\": {\n \"preAdmissionIdentifier\": {\n \"use\": \"official\",\n \"system\": \"http://www.amc.nl/zorgportal/identifiers/pre-admissions\",\n \"value\": \"93042\"\n },\n \"admitSource\": {\n \"coding\": [\n {\n \"system\": \"http://snomed.info/sct\",\n \"code\": \"305956004\",\n \"display\": \"Referral by physician\"\n }\n ]\n },\n \"dischargeDisposition\": {\n \"coding\": [\n {\n \"system\": \"http://snomed.info/sct\",\n \"code\": \"306689006\",\n \"display\": \"Discharge to home\"\n }\n ]\n }\n },\n \"serviceProvider\": {\n \"reference\": \"Organization/org123\",\n \"display\": \"University Medical Center\"\n }\n }\n }\n }\n]\n" + }, + { + "name": "encounter_discharge_request_bodies", + "default": "[\n {\n \"hookInstance\": \"f3945c69-dfbe-44vf-ba6d-3e05e123b2ea\",\n \"fhirServer\": \"https://inferno-qa.healthit.gov/reference-server/r4\",\n \"hook\": \"encounter-discharge\",\n \"fhirAuthorization\": {\n \"access_token\": \"SAMPLE_TOKEN\",\n \"token_type\": \"Bearer\",\n \"expires_in\": 300,\n \"scope\": \"user/Patient.read user/Practitioner.read user/Encounter.read\",\n \"subject\": \"cds-service\"\n },\n \"context\": {\n \"userId\": \"PractitionerRole/A2340113\",\n \"patientId\": \"1288992\",\n \"encounterId\": \"456\"\n },\n \"prefetch\": {\n \"encounter\": {\n \"resourceType\": \"Encounter\",\n \"id\": \"456\",\n \"identifier\": [\n {\n \"use\": \"official\",\n \"system\": \"http://www.amc.nl/zorgportal/identifiers/visits\",\n \"value\": \"v1451\"\n }\n ],\n \"status\": \"finished\",\n \"class\": {\n \"system\": \"http://terminology.hl7.org/CodeSystem/v3-ActCode\",\n \"code\": \"AMB\",\n \"display\": \"ambulatory\"\n },\n \"type\": [\n {\n \"coding\": [\n {\n \"system\": \"http://snomed.info/sct\",\n \"code\": \"270427003\",\n \"display\": \"Patient-initiated encounter\"\n }\n ]\n }\n ],\n \"priority\": {\n \"coding\": [\n {\n \"system\": \"http://snomed.info/sct\",\n \"code\": \"310361003\",\n \"display\": \"Non-urgent cardiological admission\"\n }\n ]\n },\n \"subject\": {\n \"reference\": \"Patient/1288992\"\n },\n \"participant\": [\n {\n \"individual\": {\n \"reference\": \"Practitioner/1234\"\n }\n }\n ],\n \"length\": {\n \"value\": 140,\n \"unit\": \"min\",\n \"system\": \"http://unitsofmeasure.org\",\n \"code\": \"min\"\n },\n \"reasonCode\": [\n {\n \"coding\": [\n {\n \"system\": \"http://snomed.info/sct\",\n \"code\": \"34068001\",\n \"display\": \"Heart valve replacement\"\n }\n ]\n }\n ],\n \"hospitalization\": {\n \"preAdmissionIdentifier\": {\n \"use\": \"official\",\n \"system\": \"http://www.amc.nl/zorgportal/identifiers/pre-admissions\",\n \"value\": \"93042\"\n },\n \"admitSource\": {\n \"coding\": [\n {\n \"system\": \"http://snomed.info/sct\",\n \"code\": \"305956004\",\n \"display\": \"Referral by physician\"\n }\n ]\n },\n \"dischargeDisposition\": {\n \"coding\": [\n {\n \"system\": \"http://snomed.info/sct\",\n \"code\": \"306689006\",\n \"display\": \"Discharge to home\"\n }\n ]\n }\n },\n \"serviceProvider\": {\n \"reference\": \"Organization/org123\",\n \"display\": \"University Medical Center\"\n }\n }\n }\n },\n {\n \"hookInstance\": \"f3945c70-dfbe-44vf-ba6d-3e05e953b2vf\",\n \"fhirServer\": \"https://inferno-qa.healthit.gov/reference-server/r4\",\n \"hook\": \"encounter-discharge\",\n \"fhirAuthorization\": {\n \"access_token\": \"SAMPLE_TOKEN\",\n \"token_type\": \"Bearer\",\n \"expires_in\": 300,\n \"scope\": \"user/Patient.read user/Practitioner.read user/Encounter.read\",\n \"subject\": \"cds-service\"\n },\n \"context\": {\n \"userId\": \"PractitionerRole/A2340113\",\n \"patientId\": \"1288992\",\n \"encounterId\": \"457\"\n },\n \"prefetch\": {\n \"encounter\": {\n \"resourceType\": \"Encounter\",\n \"id\": \"457\",\n \"identifier\": [\n {\n \"use\": \"official\",\n \"system\": \"http://www.amc.nl/zorgportal/identifiers/visits\",\n \"value\": \"v1451\"\n }\n ],\n \"status\": \"finished\",\n \"class\": {\n \"system\": \"http://terminology.hl7.org/CodeSystem/v3-ActCode\",\n \"code\": \"AMB\",\n \"display\": \"ambulatory\"\n },\n \"type\": [\n {\n \"coding\": [\n {\n \"system\": \"http://snomed.info/sct\",\n \"code\": \"270427003\",\n \"display\": \"Patient-initiated encounter\"\n }\n ]\n }\n ],\n \"priority\": {\n \"coding\": [\n {\n \"system\": \"http://snomed.info/sct\",\n \"code\": \"310361003\",\n \"display\": \"Non-urgent cardiological admission\"\n }\n ]\n },\n \"subject\": {\n \"reference\": \"Patient/1288992\"\n },\n \"participant\": [\n {\n \"individual\": {\n \"reference\": \"Practitioner/1234\"\n }\n }\n ],\n \"length\": {\n \"value\": 140,\n \"unit\": \"min\",\n \"system\": \"http://unitsofmeasure.org\",\n \"code\": \"min\"\n },\n \"reasonCode\": [\n {\n \"coding\": [\n {\n \"system\": \"http://snomed.info/sct\",\n \"code\": \"34068001\",\n \"display\": \"Heart valve replacement\"\n }\n ]\n }\n ],\n \"hospitalization\": {\n \"preAdmissionIdentifier\": {\n \"use\": \"official\",\n \"system\": \"http://www.amc.nl/zorgportal/identifiers/pre-admissions\",\n \"value\": \"93042\"\n },\n \"admitSource\": {\n \"coding\": [\n {\n \"system\": \"http://snomed.info/sct\",\n \"code\": \"305956004\",\n \"display\": \"Referral by physician\"\n }\n ]\n },\n \"dischargeDisposition\": {\n \"coding\": [\n {\n \"system\": \"http://snomed.info/sct\",\n \"code\": \"306689006\",\n \"display\": \"Discharge to home\"\n }\n ]\n }\n },\n \"serviceProvider\": {\n \"reference\": \"Organization/org123\",\n \"display\": \"University Medical Center\"\n }\n }\n }\n }\n]\n", + "description": "Provide a list of request bodies send multiple requests. e.g. [json_body_1, json_body_2]", + "title": "Request bodies collection to use to invoke the `encounter-discharge` hook", + "type": "textarea", + "value": "[\n {\n \"hookInstance\": \"f3945c69-dfbe-44vf-ba6d-3e05e123b2ea\",\n \"fhirServer\": \"https://inferno-qa.healthit.gov/reference-server/r4\",\n \"hook\": \"encounter-discharge\",\n \"fhirAuthorization\": {\n \"access_token\": \"SAMPLE_TOKEN\",\n \"token_type\": \"Bearer\",\n \"expires_in\": 300,\n \"scope\": \"user/Patient.read user/Practitioner.read user/Encounter.read\",\n \"subject\": \"cds-service\"\n },\n \"context\": {\n \"userId\": \"PractitionerRole/A2340113\",\n \"patientId\": \"1288992\",\n \"encounterId\": \"456\"\n },\n \"prefetch\": {\n \"encounter\": {\n \"resourceType\": \"Encounter\",\n \"id\": \"456\",\n \"identifier\": [\n {\n \"use\": \"official\",\n \"system\": \"http://www.amc.nl/zorgportal/identifiers/visits\",\n \"value\": \"v1451\"\n }\n ],\n \"status\": \"finished\",\n \"class\": {\n \"system\": \"http://terminology.hl7.org/CodeSystem/v3-ActCode\",\n \"code\": \"AMB\",\n \"display\": \"ambulatory\"\n },\n \"type\": [\n {\n \"coding\": [\n {\n \"system\": \"http://snomed.info/sct\",\n \"code\": \"270427003\",\n \"display\": \"Patient-initiated encounter\"\n }\n ]\n }\n ],\n \"priority\": {\n \"coding\": [\n {\n \"system\": \"http://snomed.info/sct\",\n \"code\": \"310361003\",\n \"display\": \"Non-urgent cardiological admission\"\n }\n ]\n },\n \"subject\": {\n \"reference\": \"Patient/1288992\"\n },\n \"participant\": [\n {\n \"individual\": {\n \"reference\": \"Practitioner/1234\"\n }\n }\n ],\n \"length\": {\n \"value\": 140,\n \"unit\": \"min\",\n \"system\": \"http://unitsofmeasure.org\",\n \"code\": \"min\"\n },\n \"reasonCode\": [\n {\n \"coding\": [\n {\n \"system\": \"http://snomed.info/sct\",\n \"code\": \"34068001\",\n \"display\": \"Heart valve replacement\"\n }\n ]\n }\n ],\n \"hospitalization\": {\n \"preAdmissionIdentifier\": {\n \"use\": \"official\",\n \"system\": \"http://www.amc.nl/zorgportal/identifiers/pre-admissions\",\n \"value\": \"93042\"\n },\n \"admitSource\": {\n \"coding\": [\n {\n \"system\": \"http://snomed.info/sct\",\n \"code\": \"305956004\",\n \"display\": \"Referral by physician\"\n }\n ]\n },\n \"dischargeDisposition\": {\n \"coding\": [\n {\n \"system\": \"http://snomed.info/sct\",\n \"code\": \"306689006\",\n \"display\": \"Discharge to home\"\n }\n ]\n }\n },\n \"serviceProvider\": {\n \"reference\": \"Organization/org123\",\n \"display\": \"University Medical Center\"\n }\n }\n }\n },\n {\n \"hookInstance\": \"f3945c70-dfbe-44vf-ba6d-3e05e953b2vf\",\n \"fhirServer\": \"https://inferno-qa.healthit.gov/reference-server/r4\",\n \"hook\": \"encounter-discharge\",\n \"fhirAuthorization\": {\n \"access_token\": \"SAMPLE_TOKEN\",\n \"token_type\": \"Bearer\",\n \"expires_in\": 300,\n \"scope\": \"user/Patient.read user/Practitioner.read user/Encounter.read\",\n \"subject\": \"cds-service\"\n },\n \"context\": {\n \"userId\": \"PractitionerRole/A2340113\",\n \"patientId\": \"1288992\",\n \"encounterId\": \"457\"\n },\n \"prefetch\": {\n \"encounter\": {\n \"resourceType\": \"Encounter\",\n \"id\": \"457\",\n \"identifier\": [\n {\n \"use\": \"official\",\n \"system\": \"http://www.amc.nl/zorgportal/identifiers/visits\",\n \"value\": \"v1451\"\n }\n ],\n \"status\": \"finished\",\n \"class\": {\n \"system\": \"http://terminology.hl7.org/CodeSystem/v3-ActCode\",\n \"code\": \"AMB\",\n \"display\": \"ambulatory\"\n },\n \"type\": [\n {\n \"coding\": [\n {\n \"system\": \"http://snomed.info/sct\",\n \"code\": \"270427003\",\n \"display\": \"Patient-initiated encounter\"\n }\n ]\n }\n ],\n \"priority\": {\n \"coding\": [\n {\n \"system\": \"http://snomed.info/sct\",\n \"code\": \"310361003\",\n \"display\": \"Non-urgent cardiological admission\"\n }\n ]\n },\n \"subject\": {\n \"reference\": \"Patient/1288992\"\n },\n \"participant\": [\n {\n \"individual\": {\n \"reference\": \"Practitioner/1234\"\n }\n }\n ],\n \"length\": {\n \"value\": 140,\n \"unit\": \"min\",\n \"system\": \"http://unitsofmeasure.org\",\n \"code\": \"min\"\n },\n \"reasonCode\": [\n {\n \"coding\": [\n {\n \"system\": \"http://snomed.info/sct\",\n \"code\": \"34068001\",\n \"display\": \"Heart valve replacement\"\n }\n ]\n }\n ],\n \"hospitalization\": {\n \"preAdmissionIdentifier\": {\n \"use\": \"official\",\n \"system\": \"http://www.amc.nl/zorgportal/identifiers/pre-admissions\",\n \"value\": \"93042\"\n },\n \"admitSource\": {\n \"coding\": [\n {\n \"system\": \"http://snomed.info/sct\",\n \"code\": \"305956004\",\n \"display\": \"Referral by physician\"\n }\n ]\n },\n \"dischargeDisposition\": {\n \"coding\": [\n {\n \"system\": \"http://snomed.info/sct\",\n \"code\": \"306689006\",\n \"display\": \"Discharge to home\"\n }\n ]\n }\n },\n \"serviceProvider\": {\n \"reference\": \"Organization/org123\",\n \"display\": \"University Medical Center\"\n }\n }\n }\n }\n]\n" + }, + { + "name": "order_select_request_bodies", + "default": "[\n {\n \"hookInstance\": \"d1577c69-dfbe-44ad-ba6d-3e05e953b2ea\",\n \"fhirServer\": \"https://inferno-qa.healthit.gov/reference-server/r4\",\n \"hook\": \"order-select\",\n \"fhirAuthorization\": {\n \"access_token\": \"SAMPLE_TOKEN\",\n \"token_type\": \"Bearer\",\n \"expires_in\": 300,\n \"scope\": \"patient/Patient.read patient/Observation.read\",\n \"subject\": \"cds-service4\"\n },\n \"context\": {\n \"userId\": \"Practitioner/pra1234\",\n \"patientId\": \"pat014\",\n \"encounterId\": \"enc-pat014-cold\",\n \"selections\": [\n \"MedicationRequest/pat014-mr-azathioprine\"\n ],\n \"draftOrders\": {\n \"resourceType\": \"Bundle\",\n \"entry\": [\n {\n \"fullUrl\": \"https://inferno-qa.healthit.gov/reference-server/r4/MedicationRequest/pat014-mr-azathioprine\",\n \"resource\": {\n \"resourceType\": \"MedicationRequest\",\n \"id\": \"pat014-mr-azathioprine\",\n \"meta\": {\n \"versionId\": \"1\",\n \"lastUpdated\": \"2024-04-25T17:48:34.896+00:00\",\n \"source\": \"#R0SZmSGTb4YcV7ig\"\n },\n \"identifier\": [\n {\n \"type\": {\n \"coding\": [\n {\n \"system\": \"http://terminology.hl7.org/CodeSystem/v2-0203\",\n \"code\": \"PLAC\"\n }\n ]\n },\n \"value\": \"25839b73-fcc9-4706-8c77-a806995b8109\"\n }\n ],\n \"status\": \"draft\",\n \"intent\": \"order\",\n \"medicationCodeableConcept\": {\n \"coding\": [\n {\n \"system\": \"http://www.nlm.nih.gov/research/umls/rxnorm\",\n \"code\": \"105611\",\n \"display\": \"azathioprine 50 MG Oral Tablet [Imuran]\"\n }\n ]\n },\n \"subject\": {\n \"reference\": \"Patient/pat014\",\n \"display\": \"Theodor Roosevelt\"\n },\n \"authoredOn\": \"2020-05-11\",\n \"requester\": {\n \"reference\": \"Practitioner/pra1234\",\n \"display\": \"Jane Doe\"\n },\n \"reasonCode\": [\n {\n \"coding\": [\n {\n \"system\": \"http://snomed.info/sct\",\n \"code\": \"52042003\",\n \"display\": \"Systemic lupus erythematosus glomerulonephritis syndrome, World Health Organization class V (disorder)\"\n }\n ]\n }\n ],\n \"insurance\": [\n {\n \"reference\": \"Coverage/cov014\"\n }\n ],\n \"dosageInstruction\": [\n {\n \"sequence\": 1,\n \"text\": \"50 mg PO daily for remission induction\",\n \"timing\": {\n \"repeat\": {\n \"frequency\": 1,\n \"period\": 1,\n \"periodUnit\": \"d\"\n }\n },\n \"route\": {\n \"coding\": [\n {\n \"system\": \"http://snomed.info/sct\",\n \"code\": \"26643006\",\n \"display\": \"Oral route (qualifier value)\"\n }\n ]\n },\n \"doseAndRate\": [\n {\n \"type\": {\n \"coding\": [\n {\n \"system\": \"http://terminology.hl7.org/CodeSystem/dose-rate-type\",\n \"code\": \"ordered\",\n \"display\": \"Ordered\"\n }\n ]\n },\n \"doseQuantity\": {\n \"value\": 50,\n \"unit\": \"mg\",\n \"system\": \"http://unitsofmeasure.org\",\n \"code\": \"mg\"\n }\n }\n ]\n }\n ],\n \"dispenseRequest\": {\n \"numberOfRepeatsAllowed\": 3,\n \"quantity\": {\n \"value\": 90,\n \"system\": \"http://terminology.hl7.org/CodeSystem/v3-orderableDrugForm\",\n \"code\": \"TAB\"\n }\n }\n },\n \"search\": {\n \"mode\": \"match\"\n }\n }\n ]\n }\n },\n \"prefetch\": {\n \"coverageBundle\": {\n \"resourceType\": \"Bundle\",\n \"id\": \"5b0be74e-9f57-4d11-a5e9-ec05fe9af42c\",\n \"meta\": {\n \"lastUpdated\": \"2024-05-10T03:56:46.942+00:00\"\n },\n \"type\": \"searchset\",\n \"total\": 1,\n \"link\": [\n {\n \"relation\": \"self\",\n \"url\": \"https://inferno-qa.healthit.gov/reference-server/r4/Coverage?patient=pat014\"\n }\n ],\n \"entry\": [\n {\n \"fullUrl\": \"https://inferno-qa.healthit.gov/reference-server/r4/Coverage/cov014\",\n \"resource\": {\n \"resourceType\": \"Coverage\",\n \"id\": \"cov014\",\n \"meta\": {\n \"versionId\": \"1\",\n \"lastUpdated\": \"2024-04-25T17:48:34.696+00:00\",\n \"source\": \"#XvndBJotP6fVi3pl\"\n },\n \"status\": \"active\",\n \"subscriberId\": \"10A3D58WH1600\",\n \"beneficiary\": {\n \"reference\": \"Patient/pat014\"\n },\n \"payor\": [\n {\n \"reference\": \"Organization/org1234\"\n }\n ],\n \"class\": [\n {\n \"type\": {\n \"coding\": [\n {\n \"system\": \"http://hl7.org/fhir/coverage-class\",\n \"code\": \"plan\"\n }\n ]\n },\n \"value\": \"Medicare Part B\"\n }\n ]\n },\n \"search\": {\n \"mode\": \"match\"\n }\n }\n ]\n },\n \"medicationRequestBundle\": {\n \"resourceType\": \"Bundle\",\n \"id\": \"849b9827-9a1e-4362-9407-516253badc0e\",\n \"meta\": {\n \"lastUpdated\": \"2024-05-10T04:42:06.884+00:00\"\n },\n \"type\": \"searchset\",\n \"total\": 1,\n \"link\": [\n {\n \"relation\": \"self\",\n \"url\": \"https://inferno-qa.healthit.gov/reference-server/r4/MedicationRequest?_id=pat014-mr-azathioprine&_include=MedicationRequest%3Apatient&_include=MedicationRequest%3Aintended-dispenser&_include=MedicationRequest%3Arequester%3APractitionerRole&_include=MedicationRequest%3Amedication&_include%3Aiterate=PractitionerRole%3Aorganization&_include%3Aiterate=PractitionerRole%3Apractitioner\"\n }\n ],\n \"entry\": [\n {\n \"fullUrl\": \"https://inferno-qa.healthit.gov/reference-server/r4/MedicationRequest/pat014-mr-azathioprine\",\n \"resource\": {\n \"resourceType\": \"MedicationRequest\",\n \"id\": \"pat014-mr-azathioprine\",\n \"meta\": {\n \"versionId\": \"1\",\n \"lastUpdated\": \"2024-04-25T17:48:34.896+00:00\",\n \"source\": \"#R0SZmSGTb4YcV7ig\"\n },\n \"identifier\": [\n {\n \"type\": {\n \"coding\": [\n {\n \"system\": \"http://terminology.hl7.org/CodeSystem/v2-0203\",\n \"code\": \"PLAC\"\n }\n ]\n },\n \"value\": \"25839b73-fcc9-4706-8c77-a806995b8109\"\n }\n ],\n \"status\": \"active\",\n \"intent\": \"order\",\n \"medicationCodeableConcept\": {\n \"coding\": [\n {\n \"system\": \"http://www.nlm.nih.gov/research/umls/rxnorm\",\n \"code\": \"105611\",\n \"display\": \"azathioprine 50 MG Oral Tablet [Imuran]\"\n }\n ]\n },\n \"subject\": {\n \"reference\": \"Patient/pat014\",\n \"display\": \"Theodor Roosevelt\"\n },\n \"authoredOn\": \"2020-05-11\",\n \"requester\": {\n \"reference\": \"Practitioner/pra1234\",\n \"display\": \"Jane Doe\"\n },\n \"reasonCode\": [\n {\n \"coding\": [\n {\n \"system\": \"http://snomed.info/sct\",\n \"code\": \"52042003\",\n \"display\": \"Systemic lupus erythematosus glomerulonephritis syndrome, World Health Organization class V (disorder)\"\n }\n ]\n }\n ],\n \"insurance\": [\n {\n \"reference\": \"Coverage/cov014\"\n }\n ],\n \"dosageInstruction\": [\n {\n \"sequence\": 1,\n \"text\": \"50 mg PO daily for remission induction\",\n \"timing\": {\n \"repeat\": {\n \"frequency\": 1,\n \"period\": 1,\n \"periodUnit\": \"d\"\n }\n },\n \"route\": {\n \"coding\": [\n {\n \"system\": \"http://snomed.info/sct\",\n \"code\": \"26643006\",\n \"display\": \"Oral route (qualifier value)\"\n }\n ]\n },\n \"doseAndRate\": [\n {\n \"type\": {\n \"coding\": [\n {\n \"system\": \"http://terminology.hl7.org/CodeSystem/dose-rate-type\",\n \"code\": \"ordered\",\n \"display\": \"Ordered\"\n }\n ]\n },\n \"doseQuantity\": {\n \"value\": 50,\n \"unit\": \"mg\",\n \"system\": \"http://unitsofmeasure.org\",\n \"code\": \"mg\"\n }\n }\n ]\n }\n ],\n \"dispenseRequest\": {\n \"numberOfRepeatsAllowed\": 3,\n \"quantity\": {\n \"value\": 90,\n \"system\": \"http://terminology.hl7.org/CodeSystem/v3-orderableDrugForm\",\n \"code\": \"TAB\"\n }\n }\n },\n \"search\": {\n \"mode\": \"match\"\n }\n },\n {\n \"fullUrl\": \"https://inferno-qa.healthit.gov/reference-server/r4/Patient/pat014\",\n \"resource\": {\n \"resourceType\": \"Patient\",\n \"id\": \"pat014\",\n \"meta\": {\n \"versionId\": \"1\",\n \"lastUpdated\": \"2024-04-25T17:48:34.662+00:00\",\n \"source\": \"#4RE6S8FR2Y44APk0\"\n },\n \"text\": {\n \"status\": \"generated\",\n \"div\": \"
Theodor Alan Roosevelt ROOSEVELT
Identifier0M846129001NF
Address7525 Colshire Dr
McLean VA
Date of birth04 July 1946
\"\n },\n \"identifier\": [\n {\n \"use\": \"usual\",\n \"type\": {\n \"coding\": [\n {\n \"system\": \"http://terminology.hl7.org/CodeSystem/v2-0203\",\n \"code\": \"MR\"\n }\n ],\n \"text\": \"Medical Record Number\"\n },\n \"system\": \"http://hl7.org/fhir/sid/us-medicare\",\n \"value\": \"0M846129001NF\"\n }\n ],\n \"name\": [\n {\n \"use\": \"official\",\n \"family\": \"Roosevelt\",\n \"given\": [\n \"Theodor\",\n \"Alan\",\n \"Roosevelt\"\n ]\n }\n ],\n \"gender\": \"male\",\n \"birthDate\": \"1946-07-04\",\n \"address\": [\n {\n \"use\": \"home\",\n \"type\": \"both\",\n \"line\": [\n \"7525 Colshire Dr\"\n ],\n \"city\": \"McLean\",\n \"state\": \"VA\",\n \"postalCode\": \"22102\"\n }\n ]\n },\n \"search\": {\n \"mode\": \"include\"\n }\n }\n ]\n }\n },\n \"extension\": {\n \"davinci-crd.configuration\": {\n \"coverage\": true,\n \"max-cards\": 10\n }\n }\n },\n {\n \"hookInstance\": \"d1577c69-dfbe-44ad-ba6d-3e05e953b2ea\",\n \"fhirServer\": \"https://inferno-qa.healthit.gov/reference-server/r4\",\n \"hook\": \"order-select\",\n \"fhirAuthorization\": {\n \"access_token\": \"SAMPLE_TOKEN\",\n \"token_type\": \"Bearer\",\n \"expires_in\": 300,\n \"scope\": \"patient/Patient.read patient/Observation.read\",\n \"subject\": \"cds-service4\"\n },\n \"context\": {\n \"userId\": \"Practitioner/pra1234\",\n \"patientId\": \"pat014\",\n \"encounterId\": \"enc-pat014-cold\",\n \"selections\": [\n \"MedicationRequest/pat014-mr-methotrexate\"\n ],\n \"draftOrders\": {\n \"resourceType\": \"Bundle\",\n \"entry\": [\n {\n \"fullUrl\": \"https://inferno-qa.healthit.gov/reference-server/r4/MedicationRequest/pat014-mr-methotrexate\",\n \"resource\": {\n \"resourceType\": \"MedicationRequest\",\n \"id\": \"pat014-mr-methotrexate\",\n \"meta\": {\n \"versionId\": \"1\",\n \"lastUpdated\": \"2024-04-25T17:48:34.902+00:00\",\n \"source\": \"#45wKMkOdkBDxxP7q\"\n },\n \"identifier\": [\n {\n \"type\": {\n \"coding\": [\n {\n \"system\": \"http://terminology.hl7.org/CodeSystem/v2-0203\",\n \"code\": \"PLAC\"\n }\n ]\n },\n \"value\": \"9ae058cc-ffdc-4680-b39f-6a38bfde01ac\"\n }\n ],\n \"status\": \"draft\",\n \"intent\": \"order\",\n \"medicationCodeableConcept\": {\n \"coding\": [\n {\n \"system\": \"http://www.nlm.nih.gov/research/umls/rxnorm\",\n \"code\": \"105585\",\n \"display\": \"methotrexate 2.5 MG Oral Tablet\"\n }\n ]\n },\n \"subject\": {\n \"reference\": \"Patient/pat014\",\n \"display\": \"Theodor Roosevelt\"\n },\n \"authoredOn\": \"2020-07-11\",\n \"requester\": {\n \"reference\": \"Practitioner/pra1234\",\n \"display\": \"Jane Doe\"\n },\n \"reasonCode\": [\n {\n \"coding\": [\n {\n \"system\": \"http://snomed.info/sct\",\n \"code\": \"52042003\",\n \"display\": \"Systemic lupus erythematosus glomerulonephritis syndrome, World Health Organization class V (disorder)\"\n }\n ]\n }\n ],\n \"insurance\": [\n {\n \"reference\": \"Coverage/cov014\"\n }\n ],\n \"dosageInstruction\": [\n {\n \"sequence\": 1,\n \"text\": \"7.5 mg PO daily for remission induction\",\n \"timing\": {\n \"repeat\": {\n \"frequency\": 1,\n \"period\": 1,\n \"periodUnit\": \"d\"\n }\n },\n \"route\": {\n \"coding\": [\n {\n \"system\": \"http://snomed.info/sct\",\n \"code\": \"26643006\",\n \"display\": \"Oral route (qualifier value)\"\n }\n ]\n },\n \"doseAndRate\": [\n {\n \"type\": {\n \"coding\": [\n {\n \"system\": \"http://terminology.hl7.org/CodeSystem/dose-rate-type\",\n \"code\": \"ordered\",\n \"display\": \"Ordered\"\n }\n ]\n },\n \"doseQuantity\": {\n \"value\": 7.5,\n \"unit\": \"mg\",\n \"system\": \"http://unitsofmeasure.org\",\n \"code\": \"mg\"\n }\n }\n ]\n }\n ],\n \"dispenseRequest\": {\n \"numberOfRepeatsAllowed\": 3,\n \"quantity\": {\n \"value\": 90,\n \"system\": \"http://terminology.hl7.org/CodeSystem/v3-orderableDrugForm\",\n \"code\": \"TAB\"\n }\n }\n },\n \"search\": {\n \"mode\": \"match\"\n }\n }\n ]\n }\n },\n \"prefetch\": {\n \"coverageBundle\": {\n \"resourceType\": \"Bundle\",\n \"id\": \"5b0be74e-9f57-4d11-a5e9-ec05fe9af42c\",\n \"meta\": {\n \"lastUpdated\": \"2024-05-10T03:56:46.942+00:00\"\n },\n \"type\": \"searchset\",\n \"total\": 1,\n \"link\": [\n {\n \"relation\": \"self\",\n \"url\": \"https://inferno-qa.healthit.gov/reference-server/r4/Coverage?patient=pat014\"\n }\n ],\n \"entry\": [\n {\n \"fullUrl\": \"https://inferno-qa.healthit.gov/reference-server/r4/Coverage/cov014\",\n \"resource\": {\n \"resourceType\": \"Coverage\",\n \"id\": \"cov014\",\n \"meta\": {\n \"versionId\": \"1\",\n \"lastUpdated\": \"2024-04-25T17:48:34.696+00:00\",\n \"source\": \"#XvndBJotP6fVi3pl\"\n },\n \"status\": \"active\",\n \"subscriberId\": \"10A3D58WH1600\",\n \"beneficiary\": {\n \"reference\": \"Patient/pat014\"\n },\n \"payor\": [\n {\n \"reference\": \"Organization/org1234\"\n }\n ],\n \"class\": [\n {\n \"type\": {\n \"coding\": [\n {\n \"system\": \"http://hl7.org/fhir/coverage-class\",\n \"code\": \"plan\"\n }\n ]\n },\n \"value\": \"Medicare Part B\"\n }\n ]\n },\n \"search\": {\n \"mode\": \"match\"\n }\n }\n ]\n },\n \"medicationRequestBundle\": {\n \"resourceType\": \"Bundle\",\n \"id\": \"37c876de-c44c-400b-b0a7-c71af8028c30\",\n \"meta\": {\n \"lastUpdated\": \"2024-05-10T04:47:28.765+00:00\"\n },\n \"type\": \"searchset\",\n \"total\": 1,\n \"link\": [\n {\n \"relation\": \"self\",\n \"url\": \"https://inferno-qa.healthit.gov/reference-server/r4/MedicationRequest?_id=pat014-mr-methotrexate&_include=MedicationRequest%3Apatient&_include=MedicationRequest%3Aintended-dispenser&_include=MedicationRequest%3Arequester%3APractitionerRole&_include=MedicationRequest%3Amedication&_include%3Aiterate=PractitionerRole%3Aorganization&_include%3Aiterate=PractitionerRole%3Apractitioner\"\n }\n ],\n \"entry\": [\n {\n \"fullUrl\": \"https://inferno-qa.healthit.gov/reference-server/r4/MedicationRequest/pat014-mr-methotrexate\",\n \"resource\": {\n \"resourceType\": \"MedicationRequest\",\n \"id\": \"pat014-mr-methotrexate\",\n \"meta\": {\n \"versionId\": \"1\",\n \"lastUpdated\": \"2024-04-25T17:48:34.902+00:00\",\n \"source\": \"#45wKMkOdkBDxxP7q\"\n },\n \"identifier\": [\n {\n \"type\": {\n \"coding\": [\n {\n \"system\": \"http://terminology.hl7.org/CodeSystem/v2-0203\",\n \"code\": \"PLAC\"\n }\n ]\n },\n \"value\": \"9ae058cc-ffdc-4680-b39f-6a38bfde01ac\"\n }\n ],\n \"status\": \"active\",\n \"intent\": \"order\",\n \"medicationCodeableConcept\": {\n \"coding\": [\n {\n \"system\": \"http://www.nlm.nih.gov/research/umls/rxnorm\",\n \"code\": \"105585\",\n \"display\": \"methotrexate 2.5 MG Oral Tablet\"\n }\n ]\n },\n \"subject\": {\n \"reference\": \"Patient/pat014\",\n \"display\": \"Theodor Roosevelt\"\n },\n \"authoredOn\": \"2020-07-11\",\n \"requester\": {\n \"reference\": \"Practitioner/pra1234\",\n \"display\": \"Jane Doe\"\n },\n \"reasonCode\": [\n {\n \"coding\": [\n {\n \"system\": \"http://snomed.info/sct\",\n \"code\": \"52042003\",\n \"display\": \"Systemic lupus erythematosus glomerulonephritis syndrome, World Health Organization class V (disorder)\"\n }\n ]\n }\n ],\n \"insurance\": [\n {\n \"reference\": \"Coverage/cov014\"\n }\n ],\n \"dosageInstruction\": [\n {\n \"sequence\": 1,\n \"text\": \"7.5 mg PO daily for remission induction\",\n \"timing\": {\n \"repeat\": {\n \"frequency\": 1,\n \"period\": 1,\n \"periodUnit\": \"d\"\n }\n },\n \"route\": {\n \"coding\": [\n {\n \"system\": \"http://snomed.info/sct\",\n \"code\": \"26643006\",\n \"display\": \"Oral route (qualifier value)\"\n }\n ]\n },\n \"doseAndRate\": [\n {\n \"type\": {\n \"coding\": [\n {\n \"system\": \"http://terminology.hl7.org/CodeSystem/dose-rate-type\",\n \"code\": \"ordered\",\n \"display\": \"Ordered\"\n }\n ]\n },\n \"doseQuantity\": {\n \"value\": 7.5,\n \"unit\": \"mg\",\n \"system\": \"http://unitsofmeasure.org\",\n \"code\": \"mg\"\n }\n }\n ]\n }\n ],\n \"dispenseRequest\": {\n \"numberOfRepeatsAllowed\": 3,\n \"quantity\": {\n \"value\": 90,\n \"system\": \"http://terminology.hl7.org/CodeSystem/v3-orderableDrugForm\",\n \"code\": \"TAB\"\n }\n }\n },\n \"search\": {\n \"mode\": \"match\"\n }\n },\n {\n \"fullUrl\": \"https://inferno-qa.healthit.gov/reference-server/r4/Patient/pat014\",\n \"resource\": {\n \"resourceType\": \"Patient\",\n \"id\": \"pat014\",\n \"meta\": {\n \"versionId\": \"1\",\n \"lastUpdated\": \"2024-04-25T17:48:34.662+00:00\",\n \"source\": \"#4RE6S8FR2Y44APk0\"\n },\n \"text\": {\n \"status\": \"generated\",\n \"div\": \"
Theodor Alan Roosevelt ROOSEVELT
Identifier0M846129001NF
Address7525 Colshire Dr
McLean VA
Date of birth04 July 1946
\"\n },\n \"identifier\": [\n {\n \"use\": \"usual\",\n \"type\": {\n \"coding\": [\n {\n \"system\": \"http://terminology.hl7.org/CodeSystem/v2-0203\",\n \"code\": \"MR\"\n }\n ],\n \"text\": \"Medical Record Number\"\n },\n \"system\": \"http://hl7.org/fhir/sid/us-medicare\",\n \"value\": \"0M846129001NF\"\n }\n ],\n \"name\": [\n {\n \"use\": \"official\",\n \"family\": \"Roosevelt\",\n \"given\": [\n \"Theodor\",\n \"Alan\",\n \"Roosevelt\"\n ]\n }\n ],\n \"gender\": \"male\",\n \"birthDate\": \"1946-07-04\",\n \"address\": [\n {\n \"use\": \"home\",\n \"type\": \"both\",\n \"line\": [\n \"7525 Colshire Dr\"\n ],\n \"city\": \"McLean\",\n \"state\": \"VA\",\n \"postalCode\": \"22102\"\n }\n ]\n },\n \"search\": {\n \"mode\": \"include\"\n }\n }\n ]\n }\n },\n \"extension\": {\n \"davinci-crd.configuration\": {\n \"coverage\": true,\n \"max-cards\": 10\n }\n }\n }\n]\n", + "description": "Provide a list of request bodies send multiple requests. e.g. [json_body_1, json_body_2]", + "title": "Request bodies collection to use to invoke the `order-select` hook", + "type": "textarea", + "value": "[\n {\n \"hookInstance\": \"d1577c69-dfbe-44ad-ba6d-3e05e953b2ea\",\n \"fhirServer\": \"https://inferno-qa.healthit.gov/reference-server/r4\",\n \"hook\": \"order-select\",\n \"fhirAuthorization\": {\n \"access_token\": \"SAMPLE_TOKEN\",\n \"token_type\": \"Bearer\",\n \"expires_in\": 300,\n \"scope\": \"patient/Patient.read patient/Observation.read\",\n \"subject\": \"cds-service4\"\n },\n \"context\": {\n \"userId\": \"Practitioner/pra1234\",\n \"patientId\": \"pat014\",\n \"encounterId\": \"enc-pat014-cold\",\n \"selections\": [\n \"MedicationRequest/pat014-mr-azathioprine\"\n ],\n \"draftOrders\": {\n \"resourceType\": \"Bundle\",\n \"entry\": [\n {\n \"fullUrl\": \"https://inferno-qa.healthit.gov/reference-server/r4/MedicationRequest/pat014-mr-azathioprine\",\n \"resource\": {\n \"resourceType\": \"MedicationRequest\",\n \"id\": \"pat014-mr-azathioprine\",\n \"meta\": {\n \"versionId\": \"1\",\n \"lastUpdated\": \"2024-04-25T17:48:34.896+00:00\",\n \"source\": \"#R0SZmSGTb4YcV7ig\"\n },\n \"identifier\": [\n {\n \"type\": {\n \"coding\": [\n {\n \"system\": \"http://terminology.hl7.org/CodeSystem/v2-0203\",\n \"code\": \"PLAC\"\n }\n ]\n },\n \"value\": \"25839b73-fcc9-4706-8c77-a806995b8109\"\n }\n ],\n \"status\": \"draft\",\n \"intent\": \"order\",\n \"medicationCodeableConcept\": {\n \"coding\": [\n {\n \"system\": \"http://www.nlm.nih.gov/research/umls/rxnorm\",\n \"code\": \"105611\",\n \"display\": \"azathioprine 50 MG Oral Tablet [Imuran]\"\n }\n ]\n },\n \"subject\": {\n \"reference\": \"Patient/pat014\",\n \"display\": \"Theodor Roosevelt\"\n },\n \"authoredOn\": \"2020-05-11\",\n \"requester\": {\n \"reference\": \"Practitioner/pra1234\",\n \"display\": \"Jane Doe\"\n },\n \"reasonCode\": [\n {\n \"coding\": [\n {\n \"system\": \"http://snomed.info/sct\",\n \"code\": \"52042003\",\n \"display\": \"Systemic lupus erythematosus glomerulonephritis syndrome, World Health Organization class V (disorder)\"\n }\n ]\n }\n ],\n \"insurance\": [\n {\n \"reference\": \"Coverage/cov014\"\n }\n ],\n \"dosageInstruction\": [\n {\n \"sequence\": 1,\n \"text\": \"50 mg PO daily for remission induction\",\n \"timing\": {\n \"repeat\": {\n \"frequency\": 1,\n \"period\": 1,\n \"periodUnit\": \"d\"\n }\n },\n \"route\": {\n \"coding\": [\n {\n \"system\": \"http://snomed.info/sct\",\n \"code\": \"26643006\",\n \"display\": \"Oral route (qualifier value)\"\n }\n ]\n },\n \"doseAndRate\": [\n {\n \"type\": {\n \"coding\": [\n {\n \"system\": \"http://terminology.hl7.org/CodeSystem/dose-rate-type\",\n \"code\": \"ordered\",\n \"display\": \"Ordered\"\n }\n ]\n },\n \"doseQuantity\": {\n \"value\": 50,\n \"unit\": \"mg\",\n \"system\": \"http://unitsofmeasure.org\",\n \"code\": \"mg\"\n }\n }\n ]\n }\n ],\n \"dispenseRequest\": {\n \"numberOfRepeatsAllowed\": 3,\n \"quantity\": {\n \"value\": 90,\n \"system\": \"http://terminology.hl7.org/CodeSystem/v3-orderableDrugForm\",\n \"code\": \"TAB\"\n }\n }\n },\n \"search\": {\n \"mode\": \"match\"\n }\n }\n ]\n }\n },\n \"prefetch\": {\n \"coverageBundle\": {\n \"resourceType\": \"Bundle\",\n \"id\": \"5b0be74e-9f57-4d11-a5e9-ec05fe9af42c\",\n \"meta\": {\n \"lastUpdated\": \"2024-05-10T03:56:46.942+00:00\"\n },\n \"type\": \"searchset\",\n \"total\": 1,\n \"link\": [\n {\n \"relation\": \"self\",\n \"url\": \"https://inferno-qa.healthit.gov/reference-server/r4/Coverage?patient=pat014\"\n }\n ],\n \"entry\": [\n {\n \"fullUrl\": \"https://inferno-qa.healthit.gov/reference-server/r4/Coverage/cov014\",\n \"resource\": {\n \"resourceType\": \"Coverage\",\n \"id\": \"cov014\",\n \"meta\": {\n \"versionId\": \"1\",\n \"lastUpdated\": \"2024-04-25T17:48:34.696+00:00\",\n \"source\": \"#XvndBJotP6fVi3pl\"\n },\n \"status\": \"active\",\n \"subscriberId\": \"10A3D58WH1600\",\n \"beneficiary\": {\n \"reference\": \"Patient/pat014\"\n },\n \"payor\": [\n {\n \"reference\": \"Organization/org1234\"\n }\n ],\n \"class\": [\n {\n \"type\": {\n \"coding\": [\n {\n \"system\": \"http://hl7.org/fhir/coverage-class\",\n \"code\": \"plan\"\n }\n ]\n },\n \"value\": \"Medicare Part B\"\n }\n ]\n },\n \"search\": {\n \"mode\": \"match\"\n }\n }\n ]\n },\n \"medicationRequestBundle\": {\n \"resourceType\": \"Bundle\",\n \"id\": \"849b9827-9a1e-4362-9407-516253badc0e\",\n \"meta\": {\n \"lastUpdated\": \"2024-05-10T04:42:06.884+00:00\"\n },\n \"type\": \"searchset\",\n \"total\": 1,\n \"link\": [\n {\n \"relation\": \"self\",\n \"url\": \"https://inferno-qa.healthit.gov/reference-server/r4/MedicationRequest?_id=pat014-mr-azathioprine&_include=MedicationRequest%3Apatient&_include=MedicationRequest%3Aintended-dispenser&_include=MedicationRequest%3Arequester%3APractitionerRole&_include=MedicationRequest%3Amedication&_include%3Aiterate=PractitionerRole%3Aorganization&_include%3Aiterate=PractitionerRole%3Apractitioner\"\n }\n ],\n \"entry\": [\n {\n \"fullUrl\": \"https://inferno-qa.healthit.gov/reference-server/r4/MedicationRequest/pat014-mr-azathioprine\",\n \"resource\": {\n \"resourceType\": \"MedicationRequest\",\n \"id\": \"pat014-mr-azathioprine\",\n \"meta\": {\n \"versionId\": \"1\",\n \"lastUpdated\": \"2024-04-25T17:48:34.896+00:00\",\n \"source\": \"#R0SZmSGTb4YcV7ig\"\n },\n \"identifier\": [\n {\n \"type\": {\n \"coding\": [\n {\n \"system\": \"http://terminology.hl7.org/CodeSystem/v2-0203\",\n \"code\": \"PLAC\"\n }\n ]\n },\n \"value\": \"25839b73-fcc9-4706-8c77-a806995b8109\"\n }\n ],\n \"status\": \"active\",\n \"intent\": \"order\",\n \"medicationCodeableConcept\": {\n \"coding\": [\n {\n \"system\": \"http://www.nlm.nih.gov/research/umls/rxnorm\",\n \"code\": \"105611\",\n \"display\": \"azathioprine 50 MG Oral Tablet [Imuran]\"\n }\n ]\n },\n \"subject\": {\n \"reference\": \"Patient/pat014\",\n \"display\": \"Theodor Roosevelt\"\n },\n \"authoredOn\": \"2020-05-11\",\n \"requester\": {\n \"reference\": \"Practitioner/pra1234\",\n \"display\": \"Jane Doe\"\n },\n \"reasonCode\": [\n {\n \"coding\": [\n {\n \"system\": \"http://snomed.info/sct\",\n \"code\": \"52042003\",\n \"display\": \"Systemic lupus erythematosus glomerulonephritis syndrome, World Health Organization class V (disorder)\"\n }\n ]\n }\n ],\n \"insurance\": [\n {\n \"reference\": \"Coverage/cov014\"\n }\n ],\n \"dosageInstruction\": [\n {\n \"sequence\": 1,\n \"text\": \"50 mg PO daily for remission induction\",\n \"timing\": {\n \"repeat\": {\n \"frequency\": 1,\n \"period\": 1,\n \"periodUnit\": \"d\"\n }\n },\n \"route\": {\n \"coding\": [\n {\n \"system\": \"http://snomed.info/sct\",\n \"code\": \"26643006\",\n \"display\": \"Oral route (qualifier value)\"\n }\n ]\n },\n \"doseAndRate\": [\n {\n \"type\": {\n \"coding\": [\n {\n \"system\": \"http://terminology.hl7.org/CodeSystem/dose-rate-type\",\n \"code\": \"ordered\",\n \"display\": \"Ordered\"\n }\n ]\n },\n \"doseQuantity\": {\n \"value\": 50,\n \"unit\": \"mg\",\n \"system\": \"http://unitsofmeasure.org\",\n \"code\": \"mg\"\n }\n }\n ]\n }\n ],\n \"dispenseRequest\": {\n \"numberOfRepeatsAllowed\": 3,\n \"quantity\": {\n \"value\": 90,\n \"system\": \"http://terminology.hl7.org/CodeSystem/v3-orderableDrugForm\",\n \"code\": \"TAB\"\n }\n }\n },\n \"search\": {\n \"mode\": \"match\"\n }\n },\n {\n \"fullUrl\": \"https://inferno-qa.healthit.gov/reference-server/r4/Patient/pat014\",\n \"resource\": {\n \"resourceType\": \"Patient\",\n \"id\": \"pat014\",\n \"meta\": {\n \"versionId\": \"1\",\n \"lastUpdated\": \"2024-04-25T17:48:34.662+00:00\",\n \"source\": \"#4RE6S8FR2Y44APk0\"\n },\n \"text\": {\n \"status\": \"generated\",\n \"div\": \"
Theodor Alan Roosevelt ROOSEVELT
Identifier0M846129001NF
Address7525 Colshire Dr
McLean VA
Date of birth04 July 1946
\"\n },\n \"identifier\": [\n {\n \"use\": \"usual\",\n \"type\": {\n \"coding\": [\n {\n \"system\": \"http://terminology.hl7.org/CodeSystem/v2-0203\",\n \"code\": \"MR\"\n }\n ],\n \"text\": \"Medical Record Number\"\n },\n \"system\": \"http://hl7.org/fhir/sid/us-medicare\",\n \"value\": \"0M846129001NF\"\n }\n ],\n \"name\": [\n {\n \"use\": \"official\",\n \"family\": \"Roosevelt\",\n \"given\": [\n \"Theodor\",\n \"Alan\",\n \"Roosevelt\"\n ]\n }\n ],\n \"gender\": \"male\",\n \"birthDate\": \"1946-07-04\",\n \"address\": [\n {\n \"use\": \"home\",\n \"type\": \"both\",\n \"line\": [\n \"7525 Colshire Dr\"\n ],\n \"city\": \"McLean\",\n \"state\": \"VA\",\n \"postalCode\": \"22102\"\n }\n ]\n },\n \"search\": {\n \"mode\": \"include\"\n }\n }\n ]\n }\n },\n \"extension\": {\n \"davinci-crd.configuration\": {\n \"coverage\": true,\n \"max-cards\": 10\n }\n }\n },\n {\n \"hookInstance\": \"d1577c69-dfbe-44ad-ba6d-3e05e953b2ea\",\n \"fhirServer\": \"https://inferno-qa.healthit.gov/reference-server/r4\",\n \"hook\": \"order-select\",\n \"fhirAuthorization\": {\n \"access_token\": \"SAMPLE_TOKEN\",\n \"token_type\": \"Bearer\",\n \"expires_in\": 300,\n \"scope\": \"patient/Patient.read patient/Observation.read\",\n \"subject\": \"cds-service4\"\n },\n \"context\": {\n \"userId\": \"Practitioner/pra1234\",\n \"patientId\": \"pat014\",\n \"encounterId\": \"enc-pat014-cold\",\n \"selections\": [\n \"MedicationRequest/pat014-mr-methotrexate\"\n ],\n \"draftOrders\": {\n \"resourceType\": \"Bundle\",\n \"entry\": [\n {\n \"fullUrl\": \"https://inferno-qa.healthit.gov/reference-server/r4/MedicationRequest/pat014-mr-methotrexate\",\n \"resource\": {\n \"resourceType\": \"MedicationRequest\",\n \"id\": \"pat014-mr-methotrexate\",\n \"meta\": {\n \"versionId\": \"1\",\n \"lastUpdated\": \"2024-04-25T17:48:34.902+00:00\",\n \"source\": \"#45wKMkOdkBDxxP7q\"\n },\n \"identifier\": [\n {\n \"type\": {\n \"coding\": [\n {\n \"system\": \"http://terminology.hl7.org/CodeSystem/v2-0203\",\n \"code\": \"PLAC\"\n }\n ]\n },\n \"value\": \"9ae058cc-ffdc-4680-b39f-6a38bfde01ac\"\n }\n ],\n \"status\": \"draft\",\n \"intent\": \"order\",\n \"medicationCodeableConcept\": {\n \"coding\": [\n {\n \"system\": \"http://www.nlm.nih.gov/research/umls/rxnorm\",\n \"code\": \"105585\",\n \"display\": \"methotrexate 2.5 MG Oral Tablet\"\n }\n ]\n },\n \"subject\": {\n \"reference\": \"Patient/pat014\",\n \"display\": \"Theodor Roosevelt\"\n },\n \"authoredOn\": \"2020-07-11\",\n \"requester\": {\n \"reference\": \"Practitioner/pra1234\",\n \"display\": \"Jane Doe\"\n },\n \"reasonCode\": [\n {\n \"coding\": [\n {\n \"system\": \"http://snomed.info/sct\",\n \"code\": \"52042003\",\n \"display\": \"Systemic lupus erythematosus glomerulonephritis syndrome, World Health Organization class V (disorder)\"\n }\n ]\n }\n ],\n \"insurance\": [\n {\n \"reference\": \"Coverage/cov014\"\n }\n ],\n \"dosageInstruction\": [\n {\n \"sequence\": 1,\n \"text\": \"7.5 mg PO daily for remission induction\",\n \"timing\": {\n \"repeat\": {\n \"frequency\": 1,\n \"period\": 1,\n \"periodUnit\": \"d\"\n }\n },\n \"route\": {\n \"coding\": [\n {\n \"system\": \"http://snomed.info/sct\",\n \"code\": \"26643006\",\n \"display\": \"Oral route (qualifier value)\"\n }\n ]\n },\n \"doseAndRate\": [\n {\n \"type\": {\n \"coding\": [\n {\n \"system\": \"http://terminology.hl7.org/CodeSystem/dose-rate-type\",\n \"code\": \"ordered\",\n \"display\": \"Ordered\"\n }\n ]\n },\n \"doseQuantity\": {\n \"value\": 7.5,\n \"unit\": \"mg\",\n \"system\": \"http://unitsofmeasure.org\",\n \"code\": \"mg\"\n }\n }\n ]\n }\n ],\n \"dispenseRequest\": {\n \"numberOfRepeatsAllowed\": 3,\n \"quantity\": {\n \"value\": 90,\n \"system\": \"http://terminology.hl7.org/CodeSystem/v3-orderableDrugForm\",\n \"code\": \"TAB\"\n }\n }\n },\n \"search\": {\n \"mode\": \"match\"\n }\n }\n ]\n }\n },\n \"prefetch\": {\n \"coverageBundle\": {\n \"resourceType\": \"Bundle\",\n \"id\": \"5b0be74e-9f57-4d11-a5e9-ec05fe9af42c\",\n \"meta\": {\n \"lastUpdated\": \"2024-05-10T03:56:46.942+00:00\"\n },\n \"type\": \"searchset\",\n \"total\": 1,\n \"link\": [\n {\n \"relation\": \"self\",\n \"url\": \"https://inferno-qa.healthit.gov/reference-server/r4/Coverage?patient=pat014\"\n }\n ],\n \"entry\": [\n {\n \"fullUrl\": \"https://inferno-qa.healthit.gov/reference-server/r4/Coverage/cov014\",\n \"resource\": {\n \"resourceType\": \"Coverage\",\n \"id\": \"cov014\",\n \"meta\": {\n \"versionId\": \"1\",\n \"lastUpdated\": \"2024-04-25T17:48:34.696+00:00\",\n \"source\": \"#XvndBJotP6fVi3pl\"\n },\n \"status\": \"active\",\n \"subscriberId\": \"10A3D58WH1600\",\n \"beneficiary\": {\n \"reference\": \"Patient/pat014\"\n },\n \"payor\": [\n {\n \"reference\": \"Organization/org1234\"\n }\n ],\n \"class\": [\n {\n \"type\": {\n \"coding\": [\n {\n \"system\": \"http://hl7.org/fhir/coverage-class\",\n \"code\": \"plan\"\n }\n ]\n },\n \"value\": \"Medicare Part B\"\n }\n ]\n },\n \"search\": {\n \"mode\": \"match\"\n }\n }\n ]\n },\n \"medicationRequestBundle\": {\n \"resourceType\": \"Bundle\",\n \"id\": \"37c876de-c44c-400b-b0a7-c71af8028c30\",\n \"meta\": {\n \"lastUpdated\": \"2024-05-10T04:47:28.765+00:00\"\n },\n \"type\": \"searchset\",\n \"total\": 1,\n \"link\": [\n {\n \"relation\": \"self\",\n \"url\": \"https://inferno-qa.healthit.gov/reference-server/r4/MedicationRequest?_id=pat014-mr-methotrexate&_include=MedicationRequest%3Apatient&_include=MedicationRequest%3Aintended-dispenser&_include=MedicationRequest%3Arequester%3APractitionerRole&_include=MedicationRequest%3Amedication&_include%3Aiterate=PractitionerRole%3Aorganization&_include%3Aiterate=PractitionerRole%3Apractitioner\"\n }\n ],\n \"entry\": [\n {\n \"fullUrl\": \"https://inferno-qa.healthit.gov/reference-server/r4/MedicationRequest/pat014-mr-methotrexate\",\n \"resource\": {\n \"resourceType\": \"MedicationRequest\",\n \"id\": \"pat014-mr-methotrexate\",\n \"meta\": {\n \"versionId\": \"1\",\n \"lastUpdated\": \"2024-04-25T17:48:34.902+00:00\",\n \"source\": \"#45wKMkOdkBDxxP7q\"\n },\n \"identifier\": [\n {\n \"type\": {\n \"coding\": [\n {\n \"system\": \"http://terminology.hl7.org/CodeSystem/v2-0203\",\n \"code\": \"PLAC\"\n }\n ]\n },\n \"value\": \"9ae058cc-ffdc-4680-b39f-6a38bfde01ac\"\n }\n ],\n \"status\": \"active\",\n \"intent\": \"order\",\n \"medicationCodeableConcept\": {\n \"coding\": [\n {\n \"system\": \"http://www.nlm.nih.gov/research/umls/rxnorm\",\n \"code\": \"105585\",\n \"display\": \"methotrexate 2.5 MG Oral Tablet\"\n }\n ]\n },\n \"subject\": {\n \"reference\": \"Patient/pat014\",\n \"display\": \"Theodor Roosevelt\"\n },\n \"authoredOn\": \"2020-07-11\",\n \"requester\": {\n \"reference\": \"Practitioner/pra1234\",\n \"display\": \"Jane Doe\"\n },\n \"reasonCode\": [\n {\n \"coding\": [\n {\n \"system\": \"http://snomed.info/sct\",\n \"code\": \"52042003\",\n \"display\": \"Systemic lupus erythematosus glomerulonephritis syndrome, World Health Organization class V (disorder)\"\n }\n ]\n }\n ],\n \"insurance\": [\n {\n \"reference\": \"Coverage/cov014\"\n }\n ],\n \"dosageInstruction\": [\n {\n \"sequence\": 1,\n \"text\": \"7.5 mg PO daily for remission induction\",\n \"timing\": {\n \"repeat\": {\n \"frequency\": 1,\n \"period\": 1,\n \"periodUnit\": \"d\"\n }\n },\n \"route\": {\n \"coding\": [\n {\n \"system\": \"http://snomed.info/sct\",\n \"code\": \"26643006\",\n \"display\": \"Oral route (qualifier value)\"\n }\n ]\n },\n \"doseAndRate\": [\n {\n \"type\": {\n \"coding\": [\n {\n \"system\": \"http://terminology.hl7.org/CodeSystem/dose-rate-type\",\n \"code\": \"ordered\",\n \"display\": \"Ordered\"\n }\n ]\n },\n \"doseQuantity\": {\n \"value\": 7.5,\n \"unit\": \"mg\",\n \"system\": \"http://unitsofmeasure.org\",\n \"code\": \"mg\"\n }\n }\n ]\n }\n ],\n \"dispenseRequest\": {\n \"numberOfRepeatsAllowed\": 3,\n \"quantity\": {\n \"value\": 90,\n \"system\": \"http://terminology.hl7.org/CodeSystem/v3-orderableDrugForm\",\n \"code\": \"TAB\"\n }\n }\n },\n \"search\": {\n \"mode\": \"match\"\n }\n },\n {\n \"fullUrl\": \"https://inferno-qa.healthit.gov/reference-server/r4/Patient/pat014\",\n \"resource\": {\n \"resourceType\": \"Patient\",\n \"id\": \"pat014\",\n \"meta\": {\n \"versionId\": \"1\",\n \"lastUpdated\": \"2024-04-25T17:48:34.662+00:00\",\n \"source\": \"#4RE6S8FR2Y44APk0\"\n },\n \"text\": {\n \"status\": \"generated\",\n \"div\": \"
Theodor Alan Roosevelt ROOSEVELT
Identifier0M846129001NF
Address7525 Colshire Dr
McLean VA
Date of birth04 July 1946
\"\n },\n \"identifier\": [\n {\n \"use\": \"usual\",\n \"type\": {\n \"coding\": [\n {\n \"system\": \"http://terminology.hl7.org/CodeSystem/v2-0203\",\n \"code\": \"MR\"\n }\n ],\n \"text\": \"Medical Record Number\"\n },\n \"system\": \"http://hl7.org/fhir/sid/us-medicare\",\n \"value\": \"0M846129001NF\"\n }\n ],\n \"name\": [\n {\n \"use\": \"official\",\n \"family\": \"Roosevelt\",\n \"given\": [\n \"Theodor\",\n \"Alan\",\n \"Roosevelt\"\n ]\n }\n ],\n \"gender\": \"male\",\n \"birthDate\": \"1946-07-04\",\n \"address\": [\n {\n \"use\": \"home\",\n \"type\": \"both\",\n \"line\": [\n \"7525 Colshire Dr\"\n ],\n \"city\": \"McLean\",\n \"state\": \"VA\",\n \"postalCode\": \"22102\"\n }\n ]\n },\n \"search\": {\n \"mode\": \"include\"\n }\n }\n ]\n }\n },\n \"extension\": {\n \"davinci-crd.configuration\": {\n \"coverage\": true,\n \"max-cards\": 10\n }\n }\n }\n]\n" + }, + { + "name": "order_dispatch_request_bodies", + "default": "[\n {\n \"hookInstance\": \"f3945c39-dfbe-44vf-ba6d-3e05e123b2va\",\n \"fhirServer\": \"https://inferno-qa.healthit.gov/reference-server/r4\",\n \"hook\": \"order-dispatch\",\n \"fhirAuthorization\": {\n \"access_token\": \"SAMPLE_TOKEN\",\n \"token_type\": \"Bearer\",\n \"expires_in\": 300,\n \"scope\": \"user/Patient.read user/Practitioner.read user/ServiceRequest.read\",\n \"subject\": \"cds-service\"\n },\n \"context\": {\n \"patientId\": \"pat015\",\n \"order\": \"ServiceRequest/servreq-g0180-1\",\n \"performer\": \"Practitioner/pra1255\"\n },\n \"prefetch\": {\n \"coverageBundle\": {\n \"resourceType\": \"Bundle\",\n \"type\": \"collection\",\n \"entry\": [\n {\n \"fullUrl\": \"https://inferno-qa.healthit.gov/reference-server/r4/Coverage/cov015\",\n \"resource\": {\n \"resourceType\": \"Coverage\",\n \"id\": \"cov015\",\n \"meta\": {\n \"versionId\": \"1\",\n \"lastUpdated\": \"2024-05-08T09:47:16.618-04:00\",\n \"source\": \"#2oewiwPOJlURF4aM\"\n },\n \"status\": \"active\",\n \"subscriberId\": \"10A3D58WH456\",\n \"beneficiary\": {\n \"reference\": \"Patient/pat015\"\n },\n \"payor\": [\n {\n \"reference\": \"Organization/org1234\"\n }\n ],\n \"class\": [\n {\n \"type\": {\n \"coding\": [\n {\n \"system\": \"http://hl7.org/fhir/coverage-class\",\n \"code\": \"plan\"\n }\n ]\n },\n \"value\": \"Medicare Part A\"\n }\n ]\n },\n \"search\": {\n \"mode\": \"match\"\n }\n }\n ]\n },\n \"serviceRequestBundle\": {\n \"resourceType\": \"Bundle\",\n \"id\": \"016cc2fb-60ae-4796-9932-c49cc3d1b40c\",\n \"meta\": {\n \"lastUpdated\": \"2024-05-10T01:49:20.640+00:00\"\n },\n \"type\": \"searchset\",\n \"total\": 1,\n \"link\": [\n {\n \"relation\": \"self\",\n \"url\": \"https://inferno-qa.healthit.gov/reference-server/r4/ServiceRequest?_id=servreq-g0180-1&_include=ServiceRequest%3Apatient&_include=ServiceRequest%3Aperformer&_include=ServiceRequest%3Arequester&_include%3Aiterate=PractitionerRole%3Aorganization&_include%3Aiterate=PractitionerRole%3Apractitioner\"\n }\n ],\n \"entry\": [\n {\n \"fullUrl\": \"https://inferno-qa.healthit.gov/reference-server/r4/ServiceRequest/servreq-g0180-1\",\n \"resource\": {\n \"resourceType\": \"ServiceRequest\",\n \"id\": \"servreq-g0180-1\",\n \"meta\": {\n \"versionId\": \"1\",\n \"lastUpdated\": \"2024-04-25T17:48:34.382+00:00\",\n \"source\": \"#AylXJ4OQfExfrWTQ\"\n },\n \"identifier\": [\n {\n \"type\": {\n \"coding\": [\n {\n \"system\": \"http://terminology.hl7.org/CodeSystem/v2-0203\",\n \"code\": \"PLAC\"\n }\n ]\n },\n \"value\": \"11ddab7e-1488-4848-b3d5-512d2f1e3f28\"\n }\n ],\n \"status\": \"draft\",\n \"intent\": \"order\",\n \"code\": {\n \"coding\": [\n {\n \"system\": \"https://bluebutton.cms.gov/resources/codesystem/hcpcs\",\n \"code\": \"G0180\",\n \"display\": \"Medicare-covered home health services under a home health plan of care\"\n }\n ]\n },\n \"subject\": {\n \"reference\": \"Patient/pat015\"\n },\n \"occurrenceDateTime\": \"2017-10-01\",\n \"authoredOn\": \"2017-10-04\",\n \"requester\": {\n \"display\": \"Smythe Juliette, MD\"\n },\n \"performer\": [\n {\n \"reference\": \"Practitioner/pra1255\"\n }\n ],\n \"insurance\": [\n {\n \"reference\": \"Coverage/cov016\"\n }\n ]\n },\n \"search\": {\n \"mode\": \"match\"\n }\n },\n {\n \"fullUrl\": \"https://inferno-qa.healthit.gov/reference-server/r4/Patient/pat015\",\n \"resource\": {\n \"resourceType\": \"Patient\",\n \"id\": \"pat015\",\n \"meta\": {\n \"versionId\": \"1\",\n \"lastUpdated\": \"2024-04-25T17:48:34.068+00:00\",\n \"source\": \"#fT6fENtjF2A8Ne61\"\n },\n \"text\": {\n \"status\": \"generated\",\n \"div\": \"
William Hale Oster OSTER
Identifier0M34355006FW
Address202 Burlington Road
Bedford MA
Date of birth23 February 2015
\"\n },\n \"identifier\": [\n {\n \"use\": \"usual\",\n \"type\": {\n \"coding\": [\n {\n \"system\": \"http://terminology.hl7.org/CodeSystem/v2-0203\",\n \"code\": \"MR\"\n }\n ],\n \"text\": \"Medical Record Number\"\n },\n \"system\": \"http://hl7.org/fhir/sid/us-medicare\",\n \"value\": \"0M34355006FW\"\n }\n ],\n \"name\": [\n {\n \"use\": \"official\",\n \"family\": \"Oster\",\n \"given\": [\n \"William\",\n \"Hale\",\n \"Oster\"\n ]\n }\n ],\n \"telecom\": [\n {\n \"system\": \"phone\",\n \"value\": \"(781) 555-5555\",\n \"use\": \"home\",\n \"rank\": 1\n },\n {\n \"system\": \"phone\",\n \"value\": \"(781) 555 5613\",\n \"use\": \"work\",\n \"rank\": 2\n },\n {\n \"system\": \"phone\",\n \"value\": \"(781) 555 8834\",\n \"use\": \"old\",\n \"period\": {\n \"end\": \"2014\"\n }\n }\n ],\n \"gender\": \"male\",\n \"birthDate\": \"2015-02-23\",\n \"address\": [\n {\n \"use\": \"home\",\n \"type\": \"both\",\n \"line\": [\n \"202 Burlington Road\"\n ],\n \"city\": \"Bedford\",\n \"state\": \"MA\",\n \"postalCode\": \"01730\"\n }\n ]\n },\n \"search\": {\n \"mode\": \"include\"\n }\n }\n ]\n }\n }\n },\n {\n \"hookInstance\": \"f3945c39-dfbe-44vf-ba6d-3e05e123b2va\",\n \"fhirServer\": \"https://inferno-qa.healthit.gov/reference-server/r4\",\n \"hook\": \"order-dispatch\",\n \"fhirAuthorization\": {\n \"access_token\": \"SAMPLE_TOKEN\",\n \"token_type\": \"Bearer\",\n \"expires_in\": 300,\n \"scope\": \"user/Patient.read user/Practitioner.read user/ServiceRequest.read\",\n \"subject\": \"cds-service\"\n },\n \"context\": {\n \"patientId\": \"pat015\",\n \"order\": \"DeviceRequest/devreq-015-e0250\",\n \"performer\": \"Practitioner/pra1255\"\n },\n \"prefetch\": {\n \"coverageBundle\": {\n \"resourceType\": \"Bundle\",\n \"type\": \"collection\",\n \"entry\": [\n {\n \"fullUrl\": \"https://inferno-qa.healthit.gov/reference-server/r4/Coverage/cov015\",\n \"resource\": {\n \"resourceType\": \"Coverage\",\n \"id\": \"cov015\",\n \"meta\": {\n \"versionId\": \"1\",\n \"lastUpdated\": \"2024-05-08T09:47:16.618-04:00\",\n \"source\": \"#2oewiwPOJlURF4aM\"\n },\n \"status\": \"active\",\n \"subscriberId\": \"10A3D58WH456\",\n \"beneficiary\": {\n \"reference\": \"Patient/pat015\"\n },\n \"payor\": [\n {\n \"reference\": \"Organization/org1234\"\n }\n ],\n \"class\": [\n {\n \"type\": {\n \"coding\": [\n {\n \"system\": \"http://hl7.org/fhir/coverage-class\",\n \"code\": \"plan\"\n }\n ]\n },\n \"value\": \"Medicare Part A\"\n }\n ]\n },\n \"search\": {\n \"mode\": \"match\"\n }\n }\n ]\n },\n \"deviceRequestBundle\": {\n \"resourceType\": \"Bundle\",\n \"id\": \"7ddef3dd-8f3f-41da-9682-2ed3e5633124\",\n \"meta\": {\n \"lastUpdated\": \"2024-05-10T05:12:13.040+00:00\"\n },\n \"type\": \"searchset\",\n \"total\": 1,\n \"link\": [\n {\n \"relation\": \"self\",\n \"url\": \"https://inferno-qa.healthit.gov/reference-server/r4/DeviceRequest?_id=devreq-015-e0250&_include=DeviceRequest%3Apatient&_include=DeviceRequest%3Aperformer&_include=DeviceRequest%3Arequester&_include=DeviceRequest%3Adevice&_include%3Aiterate=PractitionerRole%3Aorganization&_include%3Aiterate=PractitionerRole%3Apractitioner\"\n }\n ],\n \"entry\": [\n {\n \"fullUrl\": \"https://inferno-qa.healthit.gov/reference-server/r4/DeviceRequest/devreq-015-e0250\",\n \"resource\": {\n \"resourceType\": \"DeviceRequest\",\n \"id\": \"devreq-015-e0250\",\n \"meta\": {\n \"versionId\": \"1\",\n \"lastUpdated\": \"2024-04-25T17:48:34.356+00:00\",\n \"source\": \"#op1ghHVglWO2FxHe\",\n \"profile\": [\n \"http://hl7.org/fhir/us/davinci-crd/R4/StructureDefinition/profile-devicerequest-r4\"\n ]\n },\n \"identifier\": [\n {\n \"type\": {\n \"coding\": [\n {\n \"system\": \"http://terminology.hl7.org/CodeSystem/v2-0203\",\n \"code\": \"PLAC\"\n }\n ]\n },\n \"value\": \"be5ed14f-ed52-4a5a-ba94-29f58b14a585\"\n }\n ],\n \"status\": \"draft\",\n \"intent\": \"original-order\",\n \"codeCodeableConcept\": {\n \"coding\": [\n {\n \"system\": \"https://bluebutton.cms.gov/resources/codesystem/hcpcs\",\n \"code\": \"E0250\",\n \"display\": \"Hospital bed fixed height with any type of side rails, mattress\"\n }\n ]\n },\n \"subject\": {\n \"reference\": \"Patient/pat015\"\n },\n \"authoredOn\": \"2023-01-01T00:00:00Z\",\n \"requester\": {\n \"reference\": \"Practitioner/pra-hfairchild\"\n },\n \"performer\": {\n \"reference\": \"Practitioner/pra1234\"\n },\n \"insurance\": [\n {\n \"reference\": \"Coverage/cov015\"\n }\n ]\n },\n \"search\": {\n \"mode\": \"match\"\n }\n },\n {\n \"fullUrl\": \"https://inferno-qa.healthit.gov/reference-server/r4/Patient/pat015\",\n \"resource\": {\n \"resourceType\": \"Patient\",\n \"id\": \"pat015\",\n \"meta\": {\n \"versionId\": \"1\",\n \"lastUpdated\": \"2024-04-25T17:48:34.068+00:00\",\n \"source\": \"#fT6fENtjF2A8Ne61\"\n },\n \"text\": {\n \"status\": \"generated\",\n \"div\": \"
William Hale Oster OSTER
Identifier0M34355006FW
Address202 Burlington Road
Bedford MA
Date of birth23 February 2015
\"\n },\n \"identifier\": [\n {\n \"use\": \"usual\",\n \"type\": {\n \"coding\": [\n {\n \"system\": \"http://terminology.hl7.org/CodeSystem/v2-0203\",\n \"code\": \"MR\"\n }\n ],\n \"text\": \"Medical Record Number\"\n },\n \"system\": \"http://hl7.org/fhir/sid/us-medicare\",\n \"value\": \"0M34355006FW\"\n }\n ],\n \"name\": [\n {\n \"use\": \"official\",\n \"family\": \"Oster\",\n \"given\": [\n \"William\",\n \"Hale\",\n \"Oster\"\n ]\n }\n ],\n \"telecom\": [\n {\n \"system\": \"phone\",\n \"value\": \"(781) 555-5555\",\n \"use\": \"home\",\n \"rank\": 1\n },\n {\n \"system\": \"phone\",\n \"value\": \"(781) 555 5613\",\n \"use\": \"work\",\n \"rank\": 2\n },\n {\n \"system\": \"phone\",\n \"value\": \"(781) 555 8834\",\n \"use\": \"old\",\n \"period\": {\n \"end\": \"2014\"\n }\n }\n ],\n \"gender\": \"male\",\n \"birthDate\": \"2015-02-23\",\n \"address\": [\n {\n \"use\": \"home\",\n \"type\": \"both\",\n \"line\": [\n \"202 Burlington Road\"\n ],\n \"city\": \"Bedford\",\n \"state\": \"MA\",\n \"postalCode\": \"01730\"\n }\n ]\n },\n \"search\": {\n \"mode\": \"include\"\n }\n },\n {\n \"fullUrl\": \"https://inferno-qa.healthit.gov/reference-server/r4/Practitioner/pra1234\",\n \"resource\": {\n \"resourceType\": \"Practitioner\",\n \"id\": \"pra1234\",\n \"meta\": {\n \"versionId\": \"1\",\n \"lastUpdated\": \"2024-04-25T17:48:33.625+00:00\",\n \"source\": \"#3fLOP5tSuZrGDiae\",\n \"profile\": [\n \"http://hl7.org/fhir/us/core/StructureDefinition/us-core-practitioner\"\n ]\n },\n \"identifier\": [\n {\n \"system\": \"http://hl7.org/fhir/sid/us-npi\",\n \"value\": \"1122334455\"\n }\n ],\n \"name\": [\n {\n \"use\": \"official\",\n \"family\": \"Doe\",\n \"given\": [\n \"Jane\",\n \"Betty\"\n ],\n \"prefix\": [\n \"Dr.\"\n ]\n }\n ],\n \"telecom\": [\n {\n \"system\": \"phone\",\n \"value\": \"716-873-1557\"\n },\n {\n \"system\": \"email\",\n \"value\": \"jane.betty@myhospital.com\"\n }\n ],\n \"address\": [\n {\n \"use\": \"home\",\n \"type\": \"both\",\n \"line\": [\n \"840 Seneca St\"\n ],\n \"city\": \"Buffalo\",\n \"state\": \"NY\",\n \"postalCode\": \"14210\"\n }\n ],\n \"qualification\": [\n {\n \"identifier\": [\n {\n \"system\": \"http://example.org/UniversityIdentifier\",\n \"value\": \"12345\"\n }\n ],\n \"code\": {\n \"coding\": [\n {\n \"system\": \"http://terminology.hl7.org/CodeSystem/v2-0360/2.7\",\n \"code\": \"MD\",\n \"display\": \"Doctor of Medicine\"\n }\n ],\n \"text\": \"Doctor of Medicine\"\n },\n \"period\": {\n \"start\": \"1995\"\n },\n \"issuer\": {\n \"display\": \"Example University\"\n }\n }\n ]\n },\n \"search\": {\n \"mode\": \"include\"\n }\n },\n {\n \"fullUrl\": \"https://inferno-qa.healthit.gov/reference-server/r4/Practitioner/pra-hfairchild\",\n \"resource\": {\n \"resourceType\": \"Practitioner\",\n \"id\": \"pra-hfairchild\",\n \"meta\": {\n \"versionId\": \"1\",\n \"lastUpdated\": \"2024-04-25T17:48:33.931+00:00\",\n \"source\": \"#u2YhFvhIGG5XGNoI\"\n },\n \"identifier\": [\n {\n \"system\": \"http://hl7.org/fhir/sid/us-npi\",\n \"value\": \"1122334467\"\n }\n ],\n \"name\": [\n {\n \"use\": \"official\",\n \"family\": \"Fairchild\",\n \"given\": [\n \"Helen\"\n ],\n \"prefix\": [\n \"Ms.\"\n ]\n }\n ]\n },\n \"search\": {\n \"mode\": \"include\"\n }\n }\n ]\n }\n }\n }\n]\n", + "description": "Provide a list of request bodies send multiple requests. e.g. [json_body_1, json_body_2]", + "title": "Request bodies collection to use to invoke the `order-dispatch` hook", + "type": "textarea", + "value": "[\n {\n \"hookInstance\": \"f3945c39-dfbe-44vf-ba6d-3e05e123b2va\",\n \"fhirServer\": \"https://inferno-qa.healthit.gov/reference-server/r4\",\n \"hook\": \"order-dispatch\",\n \"fhirAuthorization\": {\n \"access_token\": \"SAMPLE_TOKEN\",\n \"token_type\": \"Bearer\",\n \"expires_in\": 300,\n \"scope\": \"user/Patient.read user/Practitioner.read user/ServiceRequest.read\",\n \"subject\": \"cds-service\"\n },\n \"context\": {\n \"patientId\": \"pat015\",\n \"order\": \"ServiceRequest/servreq-g0180-1\",\n \"performer\": \"Practitioner/pra1255\"\n },\n \"prefetch\": {\n \"coverageBundle\": {\n \"resourceType\": \"Bundle\",\n \"type\": \"collection\",\n \"entry\": [\n {\n \"fullUrl\": \"https://inferno-qa.healthit.gov/reference-server/r4/Coverage/cov015\",\n \"resource\": {\n \"resourceType\": \"Coverage\",\n \"id\": \"cov015\",\n \"meta\": {\n \"versionId\": \"1\",\n \"lastUpdated\": \"2024-05-08T09:47:16.618-04:00\",\n \"source\": \"#2oewiwPOJlURF4aM\"\n },\n \"status\": \"active\",\n \"subscriberId\": \"10A3D58WH456\",\n \"beneficiary\": {\n \"reference\": \"Patient/pat015\"\n },\n \"payor\": [\n {\n \"reference\": \"Organization/org1234\"\n }\n ],\n \"class\": [\n {\n \"type\": {\n \"coding\": [\n {\n \"system\": \"http://hl7.org/fhir/coverage-class\",\n \"code\": \"plan\"\n }\n ]\n },\n \"value\": \"Medicare Part A\"\n }\n ]\n },\n \"search\": {\n \"mode\": \"match\"\n }\n }\n ]\n },\n \"serviceRequestBundle\": {\n \"resourceType\": \"Bundle\",\n \"id\": \"016cc2fb-60ae-4796-9932-c49cc3d1b40c\",\n \"meta\": {\n \"lastUpdated\": \"2024-05-10T01:49:20.640+00:00\"\n },\n \"type\": \"searchset\",\n \"total\": 1,\n \"link\": [\n {\n \"relation\": \"self\",\n \"url\": \"https://inferno-qa.healthit.gov/reference-server/r4/ServiceRequest?_id=servreq-g0180-1&_include=ServiceRequest%3Apatient&_include=ServiceRequest%3Aperformer&_include=ServiceRequest%3Arequester&_include%3Aiterate=PractitionerRole%3Aorganization&_include%3Aiterate=PractitionerRole%3Apractitioner\"\n }\n ],\n \"entry\": [\n {\n \"fullUrl\": \"https://inferno-qa.healthit.gov/reference-server/r4/ServiceRequest/servreq-g0180-1\",\n \"resource\": {\n \"resourceType\": \"ServiceRequest\",\n \"id\": \"servreq-g0180-1\",\n \"meta\": {\n \"versionId\": \"1\",\n \"lastUpdated\": \"2024-04-25T17:48:34.382+00:00\",\n \"source\": \"#AylXJ4OQfExfrWTQ\"\n },\n \"identifier\": [\n {\n \"type\": {\n \"coding\": [\n {\n \"system\": \"http://terminology.hl7.org/CodeSystem/v2-0203\",\n \"code\": \"PLAC\"\n }\n ]\n },\n \"value\": \"11ddab7e-1488-4848-b3d5-512d2f1e3f28\"\n }\n ],\n \"status\": \"draft\",\n \"intent\": \"order\",\n \"code\": {\n \"coding\": [\n {\n \"system\": \"https://bluebutton.cms.gov/resources/codesystem/hcpcs\",\n \"code\": \"G0180\",\n \"display\": \"Medicare-covered home health services under a home health plan of care\"\n }\n ]\n },\n \"subject\": {\n \"reference\": \"Patient/pat015\"\n },\n \"occurrenceDateTime\": \"2017-10-01\",\n \"authoredOn\": \"2017-10-04\",\n \"requester\": {\n \"display\": \"Smythe Juliette, MD\"\n },\n \"performer\": [\n {\n \"reference\": \"Practitioner/pra1255\"\n }\n ],\n \"insurance\": [\n {\n \"reference\": \"Coverage/cov016\"\n }\n ]\n },\n \"search\": {\n \"mode\": \"match\"\n }\n },\n {\n \"fullUrl\": \"https://inferno-qa.healthit.gov/reference-server/r4/Patient/pat015\",\n \"resource\": {\n \"resourceType\": \"Patient\",\n \"id\": \"pat015\",\n \"meta\": {\n \"versionId\": \"1\",\n \"lastUpdated\": \"2024-04-25T17:48:34.068+00:00\",\n \"source\": \"#fT6fENtjF2A8Ne61\"\n },\n \"text\": {\n \"status\": \"generated\",\n \"div\": \"
William Hale Oster OSTER
Identifier0M34355006FW
Address202 Burlington Road
Bedford MA
Date of birth23 February 2015
\"\n },\n \"identifier\": [\n {\n \"use\": \"usual\",\n \"type\": {\n \"coding\": [\n {\n \"system\": \"http://terminology.hl7.org/CodeSystem/v2-0203\",\n \"code\": \"MR\"\n }\n ],\n \"text\": \"Medical Record Number\"\n },\n \"system\": \"http://hl7.org/fhir/sid/us-medicare\",\n \"value\": \"0M34355006FW\"\n }\n ],\n \"name\": [\n {\n \"use\": \"official\",\n \"family\": \"Oster\",\n \"given\": [\n \"William\",\n \"Hale\",\n \"Oster\"\n ]\n }\n ],\n \"telecom\": [\n {\n \"system\": \"phone\",\n \"value\": \"(781) 555-5555\",\n \"use\": \"home\",\n \"rank\": 1\n },\n {\n \"system\": \"phone\",\n \"value\": \"(781) 555 5613\",\n \"use\": \"work\",\n \"rank\": 2\n },\n {\n \"system\": \"phone\",\n \"value\": \"(781) 555 8834\",\n \"use\": \"old\",\n \"period\": {\n \"end\": \"2014\"\n }\n }\n ],\n \"gender\": \"male\",\n \"birthDate\": \"2015-02-23\",\n \"address\": [\n {\n \"use\": \"home\",\n \"type\": \"both\",\n \"line\": [\n \"202 Burlington Road\"\n ],\n \"city\": \"Bedford\",\n \"state\": \"MA\",\n \"postalCode\": \"01730\"\n }\n ]\n },\n \"search\": {\n \"mode\": \"include\"\n }\n }\n ]\n }\n }\n },\n {\n \"hookInstance\": \"f3945c39-dfbe-44vf-ba6d-3e05e123b2va\",\n \"fhirServer\": \"https://inferno-qa.healthit.gov/reference-server/r4\",\n \"hook\": \"order-dispatch\",\n \"fhirAuthorization\": {\n \"access_token\": \"SAMPLE_TOKEN\",\n \"token_type\": \"Bearer\",\n \"expires_in\": 300,\n \"scope\": \"user/Patient.read user/Practitioner.read user/ServiceRequest.read\",\n \"subject\": \"cds-service\"\n },\n \"context\": {\n \"patientId\": \"pat015\",\n \"order\": \"DeviceRequest/devreq-015-e0250\",\n \"performer\": \"Practitioner/pra1255\"\n },\n \"prefetch\": {\n \"coverageBundle\": {\n \"resourceType\": \"Bundle\",\n \"type\": \"collection\",\n \"entry\": [\n {\n \"fullUrl\": \"https://inferno-qa.healthit.gov/reference-server/r4/Coverage/cov015\",\n \"resource\": {\n \"resourceType\": \"Coverage\",\n \"id\": \"cov015\",\n \"meta\": {\n \"versionId\": \"1\",\n \"lastUpdated\": \"2024-05-08T09:47:16.618-04:00\",\n \"source\": \"#2oewiwPOJlURF4aM\"\n },\n \"status\": \"active\",\n \"subscriberId\": \"10A3D58WH456\",\n \"beneficiary\": {\n \"reference\": \"Patient/pat015\"\n },\n \"payor\": [\n {\n \"reference\": \"Organization/org1234\"\n }\n ],\n \"class\": [\n {\n \"type\": {\n \"coding\": [\n {\n \"system\": \"http://hl7.org/fhir/coverage-class\",\n \"code\": \"plan\"\n }\n ]\n },\n \"value\": \"Medicare Part A\"\n }\n ]\n },\n \"search\": {\n \"mode\": \"match\"\n }\n }\n ]\n },\n \"deviceRequestBundle\": {\n \"resourceType\": \"Bundle\",\n \"id\": \"7ddef3dd-8f3f-41da-9682-2ed3e5633124\",\n \"meta\": {\n \"lastUpdated\": \"2024-05-10T05:12:13.040+00:00\"\n },\n \"type\": \"searchset\",\n \"total\": 1,\n \"link\": [\n {\n \"relation\": \"self\",\n \"url\": \"https://inferno-qa.healthit.gov/reference-server/r4/DeviceRequest?_id=devreq-015-e0250&_include=DeviceRequest%3Apatient&_include=DeviceRequest%3Aperformer&_include=DeviceRequest%3Arequester&_include=DeviceRequest%3Adevice&_include%3Aiterate=PractitionerRole%3Aorganization&_include%3Aiterate=PractitionerRole%3Apractitioner\"\n }\n ],\n \"entry\": [\n {\n \"fullUrl\": \"https://inferno-qa.healthit.gov/reference-server/r4/DeviceRequest/devreq-015-e0250\",\n \"resource\": {\n \"resourceType\": \"DeviceRequest\",\n \"id\": \"devreq-015-e0250\",\n \"meta\": {\n \"versionId\": \"1\",\n \"lastUpdated\": \"2024-04-25T17:48:34.356+00:00\",\n \"source\": \"#op1ghHVglWO2FxHe\",\n \"profile\": [\n \"http://hl7.org/fhir/us/davinci-crd/R4/StructureDefinition/profile-devicerequest-r4\"\n ]\n },\n \"identifier\": [\n {\n \"type\": {\n \"coding\": [\n {\n \"system\": \"http://terminology.hl7.org/CodeSystem/v2-0203\",\n \"code\": \"PLAC\"\n }\n ]\n },\n \"value\": \"be5ed14f-ed52-4a5a-ba94-29f58b14a585\"\n }\n ],\n \"status\": \"draft\",\n \"intent\": \"original-order\",\n \"codeCodeableConcept\": {\n \"coding\": [\n {\n \"system\": \"https://bluebutton.cms.gov/resources/codesystem/hcpcs\",\n \"code\": \"E0250\",\n \"display\": \"Hospital bed fixed height with any type of side rails, mattress\"\n }\n ]\n },\n \"subject\": {\n \"reference\": \"Patient/pat015\"\n },\n \"authoredOn\": \"2023-01-01T00:00:00Z\",\n \"requester\": {\n \"reference\": \"Practitioner/pra-hfairchild\"\n },\n \"performer\": {\n \"reference\": \"Practitioner/pra1234\"\n },\n \"insurance\": [\n {\n \"reference\": \"Coverage/cov015\"\n }\n ]\n },\n \"search\": {\n \"mode\": \"match\"\n }\n },\n {\n \"fullUrl\": \"https://inferno-qa.healthit.gov/reference-server/r4/Patient/pat015\",\n \"resource\": {\n \"resourceType\": \"Patient\",\n \"id\": \"pat015\",\n \"meta\": {\n \"versionId\": \"1\",\n \"lastUpdated\": \"2024-04-25T17:48:34.068+00:00\",\n \"source\": \"#fT6fENtjF2A8Ne61\"\n },\n \"text\": {\n \"status\": \"generated\",\n \"div\": \"
William Hale Oster OSTER
Identifier0M34355006FW
Address202 Burlington Road
Bedford MA
Date of birth23 February 2015
\"\n },\n \"identifier\": [\n {\n \"use\": \"usual\",\n \"type\": {\n \"coding\": [\n {\n \"system\": \"http://terminology.hl7.org/CodeSystem/v2-0203\",\n \"code\": \"MR\"\n }\n ],\n \"text\": \"Medical Record Number\"\n },\n \"system\": \"http://hl7.org/fhir/sid/us-medicare\",\n \"value\": \"0M34355006FW\"\n }\n ],\n \"name\": [\n {\n \"use\": \"official\",\n \"family\": \"Oster\",\n \"given\": [\n \"William\",\n \"Hale\",\n \"Oster\"\n ]\n }\n ],\n \"telecom\": [\n {\n \"system\": \"phone\",\n \"value\": \"(781) 555-5555\",\n \"use\": \"home\",\n \"rank\": 1\n },\n {\n \"system\": \"phone\",\n \"value\": \"(781) 555 5613\",\n \"use\": \"work\",\n \"rank\": 2\n },\n {\n \"system\": \"phone\",\n \"value\": \"(781) 555 8834\",\n \"use\": \"old\",\n \"period\": {\n \"end\": \"2014\"\n }\n }\n ],\n \"gender\": \"male\",\n \"birthDate\": \"2015-02-23\",\n \"address\": [\n {\n \"use\": \"home\",\n \"type\": \"both\",\n \"line\": [\n \"202 Burlington Road\"\n ],\n \"city\": \"Bedford\",\n \"state\": \"MA\",\n \"postalCode\": \"01730\"\n }\n ]\n },\n \"search\": {\n \"mode\": \"include\"\n }\n },\n {\n \"fullUrl\": \"https://inferno-qa.healthit.gov/reference-server/r4/Practitioner/pra1234\",\n \"resource\": {\n \"resourceType\": \"Practitioner\",\n \"id\": \"pra1234\",\n \"meta\": {\n \"versionId\": \"1\",\n \"lastUpdated\": \"2024-04-25T17:48:33.625+00:00\",\n \"source\": \"#3fLOP5tSuZrGDiae\",\n \"profile\": [\n \"http://hl7.org/fhir/us/core/StructureDefinition/us-core-practitioner\"\n ]\n },\n \"identifier\": [\n {\n \"system\": \"http://hl7.org/fhir/sid/us-npi\",\n \"value\": \"1122334455\"\n }\n ],\n \"name\": [\n {\n \"use\": \"official\",\n \"family\": \"Doe\",\n \"given\": [\n \"Jane\",\n \"Betty\"\n ],\n \"prefix\": [\n \"Dr.\"\n ]\n }\n ],\n \"telecom\": [\n {\n \"system\": \"phone\",\n \"value\": \"716-873-1557\"\n },\n {\n \"system\": \"email\",\n \"value\": \"jane.betty@myhospital.com\"\n }\n ],\n \"address\": [\n {\n \"use\": \"home\",\n \"type\": \"both\",\n \"line\": [\n \"840 Seneca St\"\n ],\n \"city\": \"Buffalo\",\n \"state\": \"NY\",\n \"postalCode\": \"14210\"\n }\n ],\n \"qualification\": [\n {\n \"identifier\": [\n {\n \"system\": \"http://example.org/UniversityIdentifier\",\n \"value\": \"12345\"\n }\n ],\n \"code\": {\n \"coding\": [\n {\n \"system\": \"http://terminology.hl7.org/CodeSystem/v2-0360/2.7\",\n \"code\": \"MD\",\n \"display\": \"Doctor of Medicine\"\n }\n ],\n \"text\": \"Doctor of Medicine\"\n },\n \"period\": {\n \"start\": \"1995\"\n },\n \"issuer\": {\n \"display\": \"Example University\"\n }\n }\n ]\n },\n \"search\": {\n \"mode\": \"include\"\n }\n },\n {\n \"fullUrl\": \"https://inferno-qa.healthit.gov/reference-server/r4/Practitioner/pra-hfairchild\",\n \"resource\": {\n \"resourceType\": \"Practitioner\",\n \"id\": \"pra-hfairchild\",\n \"meta\": {\n \"versionId\": \"1\",\n \"lastUpdated\": \"2024-04-25T17:48:33.931+00:00\",\n \"source\": \"#u2YhFvhIGG5XGNoI\"\n },\n \"identifier\": [\n {\n \"system\": \"http://hl7.org/fhir/sid/us-npi\",\n \"value\": \"1122334467\"\n }\n ],\n \"name\": [\n {\n \"use\": \"official\",\n \"family\": \"Fairchild\",\n \"given\": [\n \"Helen\"\n ],\n \"prefix\": [\n \"Ms.\"\n ]\n }\n ]\n },\n \"search\": {\n \"mode\": \"include\"\n }\n }\n ]\n }\n }\n }\n]\n" + }, + { + "name": "order_sign_request_bodies", + "default": "[\n {\n \"hookInstance\": \"d1577c69-dfbe-44ad-ba6d-3e05e953b2ea\",\n \"fhirServer\": \"https://inferno-qa.healthit.gov/reference-server/r4\",\n \"hook\": \"order-sign\",\n \"fhirAuthorization\": {\n \"access_token\": \"SAMPLE_TOKEN\",\n \"token_type\": \"Bearer\",\n \"expires_in\": 300,\n \"scope\": \"patient/Patient.read patient/Observation.read\",\n \"subject\": \"cds-service4\"\n },\n \"context\": {\n \"userId\": \"Practitioner/pra1234\",\n \"patientId\": \"pat015\",\n \"encounterId\": \"enc-pat014\",\n \"draftOrders\": {\n \"resourceType\": \"Bundle\",\n \"entry\": [\n {\n \"resource\": {\n \"resourceType\": \"DeviceRequest\",\n \"id\": \"devreq-015-e0250\",\n \"meta\": {\n \"versionId\": \"1\",\n \"lastUpdated\": \"2024-05-08T09:47:16.992-04:00\",\n \"source\": \"#Odh5ejWjud85tvNJ\",\n \"profile\": [\n \"http://hl7.org/fhir/us/davinci-crd/R4/StructureDefinition/profile-devicerequest-r4\"\n ]\n },\n \"identifier\": [\n {\n \"type\": {\n \"coding\": [\n {\n \"system\": \"http://terminology.hl7.org/CodeSystem/v2-0203\",\n \"code\": \"PLAC\"\n }\n ]\n },\n \"value\": \"f105372f-bbef-442c-ad7a-708fee7f8c93\"\n }\n ],\n \"status\": \"draft\",\n \"intent\": \"original-order\",\n \"codeCodeableConcept\": {\n \"coding\": [\n {\n \"system\": \"https://bluebutton.cms.gov/resources/codesystem/hcpcs\",\n \"code\": \"E0250\",\n \"display\": \"Hospital bed fixed height with any type of side rails, mattress\"\n }\n ]\n },\n \"subject\": {\n \"reference\": \"Patient/pat015\"\n },\n \"authoredOn\": \"2023-01-01T00:00:00Z\",\n \"requester\": {\n \"reference\": \"Practitioner/pra-hfairchild\"\n },\n \"performer\": {\n \"reference\": \"Practitioner/pra1234\"\n },\n \"insurance\": [\n {\n \"reference\": \"Coverage/cov015\"\n }\n ]\n }\n }\n ]\n }\n },\n \"extension\": {\n \"davinci-crd.configuration\": {\n \"alt-drug\": true\n }\n },\n \"prefetch\": {\n \"coverageBundle\": {\n \"resourceType\": \"Bundle\",\n \"type\": \"collection\",\n \"entry\": [\n {\n \"fullUrl\": \"https://inferno-qa.healthit.gov/reference-server/r4/Coverage/cov015\",\n \"resource\": {\n \"resourceType\": \"Coverage\",\n \"id\": \"cov015\",\n \"meta\": {\n \"versionId\": \"1\",\n \"lastUpdated\": \"2024-05-08T09:47:16.618-04:00\",\n \"source\": \"#2oewiwPOJlURF4aM\"\n },\n \"status\": \"active\",\n \"subscriberId\": \"10A3D58WH456\",\n \"beneficiary\": {\n \"reference\": \"Patient/pat015\"\n },\n \"payor\": [\n {\n \"reference\": \"Organization/org1234\"\n }\n ],\n \"class\": [\n {\n \"type\": {\n \"coding\": [\n {\n \"system\": \"http://hl7.org/fhir/coverage-class\",\n \"code\": \"plan\"\n }\n ]\n },\n \"value\": \"Medicare Part A\"\n }\n ]\n },\n \"search\": {\n \"mode\": \"match\"\n }\n }\n ]\n },\n \"deviceRequestBundle\": {\n \"resourceType\": \"Bundle\",\n \"type\": \"collection\",\n \"entry\": [\n {\n \"fullUrl\": \"https://inferno-qa.healthit.gov/reference-server/r4/DeviceRequest/devreq-015-e0250\",\n \"resource\": {\n \"resourceType\": \"DeviceRequest\",\n \"id\": \"devreq-015-e0250\",\n \"meta\": {\n \"versionId\": \"1\",\n \"lastUpdated\": \"2024-05-08T09:47:16.992-04:00\",\n \"source\": \"#Odh5ejWjud85tvNJ\",\n \"profile\": [\n \"http://hl7.org/fhir/us/davinci-crd/R4/StructureDefinition/profile-devicerequest-r4\"\n ]\n },\n \"identifier\": [\n {\n \"type\": {\n \"coding\": [\n {\n \"system\": \"http://terminology.hl7.org/CodeSystem/v2-0203\",\n \"code\": \"PLAC\"\n }\n ]\n },\n \"value\": \"f105372f-bbef-442c-ad7a-708fee7f8c93\"\n }\n ],\n \"status\": \"draft\",\n \"intent\": \"original-order\",\n \"codeCodeableConcept\": {\n \"coding\": [\n {\n \"system\": \"https://bluebutton.cms.gov/resources/codesystem/hcpcs\",\n \"code\": \"E0250\",\n \"display\": \"Hospital bed fixed height with any type of side rails, mattress\"\n }\n ]\n },\n \"subject\": {\n \"reference\": \"Patient/pat015\"\n },\n \"authoredOn\": \"2023-01-01T00:00:00Z\",\n \"requester\": {\n \"reference\": \"Practitioner/pra-hfairchild\"\n },\n \"performer\": {\n \"reference\": \"Practitioner/pra1234\"\n },\n \"insurance\": [\n {\n \"reference\": \"Coverage/cov015\"\n }\n ]\n },\n \"search\": {\n \"mode\": \"match\"\n }\n },\n {\n \"fullUrl\": \"https://inferno-qa.healthit.gov/reference-server/r4/Patient/pat015\",\n \"resource\": {\n \"resourceType\": \"Patient\",\n \"id\": \"pat015\",\n \"meta\": {\n \"versionId\": \"1\",\n \"lastUpdated\": \"2024-05-08T09:47:16.555-04:00\",\n \"source\": \"#0mrTfQICzelSBeef\"\n },\n \"text\": {\n \"status\": \"generated\",\n \"div\": \"
William Hale Oster OSTER
Identifier0M34355006FW
Address202 Burlington Road
Bedford MA
Date of birth23 February 2015
\"\n },\n \"identifier\": [\n {\n \"use\": \"usual\",\n \"type\": {\n \"coding\": [\n {\n \"system\": \"http://terminology.hl7.org/CodeSystem/v2-0203\",\n \"code\": \"MR\"\n }\n ],\n \"text\": \"Medical Record Number\"\n },\n \"system\": \"http://hl7.org/fhir/sid/us-medicare\",\n \"value\": \"0M34355006FW\"\n }\n ],\n \"name\": [\n {\n \"use\": \"official\",\n \"family\": \"Oster\",\n \"given\": [\n \"William\",\n \"Hale\",\n \"Oster\"\n ]\n }\n ],\n \"telecom\": [\n {\n \"system\": \"phone\",\n \"value\": \"(781) 555-5555\",\n \"use\": \"home\",\n \"rank\": 1\n },\n {\n \"system\": \"phone\",\n \"value\": \"(781) 555 5613\",\n \"use\": \"work\",\n \"rank\": 2\n },\n {\n \"system\": \"phone\",\n \"value\": \"(781) 555 8834\",\n \"use\": \"old\",\n \"period\": {\n \"end\": \"2014\"\n }\n }\n ],\n \"gender\": \"male\",\n \"birthDate\": \"2015-02-23\",\n \"address\": [\n {\n \"use\": \"home\",\n \"type\": \"both\",\n \"line\": [\n \"202 Burlington Road\"\n ],\n \"city\": \"Bedford\",\n \"state\": \"MA\",\n \"postalCode\": \"01730\"\n }\n ]\n },\n \"search\": {\n \"mode\": \"include\"\n }\n },\n {\n \"fullUrl\": \"https://inferno-qa.healthit.gov/reference-server/r4/Practitioner/pra1234\",\n \"resource\": {\n \"resourceType\": \"Practitioner\",\n \"id\": \"pra1234\",\n \"meta\": {\n \"versionId\": \"1\",\n \"lastUpdated\": \"2024-05-08T09:47:15.668-04:00\",\n \"source\": \"#YCao6W9MbpwL2D8L\",\n \"profile\": [\n \"http://hl7.org/fhir/us/core/StructureDefinition/us-core-practitioner\"\n ]\n },\n \"identifier\": [\n {\n \"system\": \"http://hl7.org/fhir/sid/us-npi\",\n \"value\": \"1122334455\"\n }\n ],\n \"name\": [\n {\n \"use\": \"official\",\n \"family\": \"Doe\",\n \"given\": [\n \"Jane\",\n \"Betty\"\n ],\n \"prefix\": [\n \"Dr.\"\n ]\n }\n ],\n \"telecom\": [\n {\n \"system\": \"phone\",\n \"value\": \"716-873-1557\"\n },\n {\n \"system\": \"email\",\n \"value\": \"jane.betty@myhospital.com\"\n }\n ],\n \"address\": [\n {\n \"use\": \"home\",\n \"type\": \"both\",\n \"line\": [\n \"840 Seneca St\"\n ],\n \"city\": \"Buffalo\",\n \"state\": \"NY\",\n \"postalCode\": \"14210\"\n }\n ],\n \"qualification\": [\n {\n \"identifier\": [\n {\n \"system\": \"http://example.org/UniversityIdentifier\",\n \"value\": \"12345\"\n }\n ],\n \"code\": {\n \"coding\": [\n {\n \"system\": \"http://terminology.hl7.org/CodeSystem/v2-0360/2.7\",\n \"code\": \"MD\",\n \"display\": \"Doctor of Medicine\"\n }\n ],\n \"text\": \"Doctor of Medicine\"\n },\n \"period\": {\n \"start\": \"1995\"\n },\n \"issuer\": {\n \"display\": \"Example University\"\n }\n }\n ]\n },\n \"search\": {\n \"mode\": \"include\"\n }\n },\n {\n \"fullUrl\": \"https://inferno-qa.healthit.gov/reference-server/r4/Practitioner/pra-hfairchild\",\n \"resource\": {\n \"resourceType\": \"Practitioner\",\n \"id\": \"pra-hfairchild\",\n \"meta\": {\n \"versionId\": \"1\",\n \"lastUpdated\": \"2024-05-08T09:47:16.289-04:00\",\n \"source\": \"#z4T7ZRvSCFEfONs9\"\n },\n \"identifier\": [\n {\n \"system\": \"http://hl7.org/fhir/sid/us-npi\",\n \"value\": \"1122334467\"\n }\n ],\n \"name\": [\n {\n \"use\": \"official\",\n \"family\": \"Fairchild\",\n \"given\": [\n \"Helen\"\n ],\n \"prefix\": [\n \"Ms.\"\n ]\n }\n ]\n },\n \"search\": {\n \"mode\": \"include\"\n }\n }\n ]\n }\n }\n },\n {\n \"hookInstance\": \"d1577c69-dfbe-44ad-ba6d-3e05e953b2ea\",\n \"fhirServer\": \"https://inferno-qa.healthit.gov/reference-server/r4\",\n \"hook\": \"order-sign\",\n \"fhirAuthorization\": {\n \"access_token\": \"SAMPLE_TOKEN\",\n \"token_type\": \"Bearer\",\n \"expires_in\": 300,\n \"scope\": \"patient/Patient.read patient/Observation.read\",\n \"subject\": \"cds-service4\"\n },\n \"context\": {\n \"userId\": \"Practitioner/pra1255\",\n \"patientId\": \"pat015\",\n \"encounterId\": \"enc-pat014\",\n \"draftOrders\": {\n \"resourceType\": \"Bundle\",\n \"entry\": [\n {\n \"resource\": {\n \"resourceType\": \"ServiceRequest\",\n \"id\": \"servreq-g0180-1\",\n \"meta\": {\n \"versionId\": \"1\",\n \"lastUpdated\": \"2024-04-25T17:48:34.382+00:00\",\n \"source\": \"#AylXJ4OQfExfrWTQ\"\n },\n \"identifier\": [\n {\n \"type\": {\n \"coding\": [\n {\n \"system\": \"http://terminology.hl7.org/CodeSystem/v2-0203\",\n \"code\": \"PLAC\"\n }\n ]\n },\n \"value\": \"11ddab7e-1488-4848-b3d5-512d2f1e3f28\"\n }\n ],\n \"status\": \"draft\",\n \"intent\": \"order\",\n \"code\": {\n \"coding\": [\n {\n \"system\": \"https://bluebutton.cms.gov/resources/codesystem/hcpcs\",\n \"code\": \"G0180\",\n \"display\": \"Medicare-covered home health services under a home health plan of care\"\n }\n ]\n },\n \"subject\": {\n \"reference\": \"Patient/pat015\"\n },\n \"occurrenceDateTime\": \"2017-10-01\",\n \"authoredOn\": \"2017-10-04\",\n \"requester\": {\n \"display\": \"Smythe Juliette, MD\"\n },\n \"performer\": [\n {\n \"reference\": \"Practitioner/pra1255\"\n }\n ],\n \"insurance\": [\n {\n \"reference\": \"Coverage/cov015\"\n }\n ]\n }\n }\n ]\n }\n },\n \"extension\": {\n \"davinci-crd.configuration\": {\n \"alt-drug\": true,\n \"coverage\": true,\n \"dtr-clin\": true,\n \"max-cards\": 10\n }\n },\n \"prefetch\": {\n \"coverageBundle\": {\n \"resourceType\": \"Bundle\",\n \"type\": \"collection\",\n \"entry\": [\n {\n \"fullUrl\": \"https://inferno-qa.healthit.gov/reference-server/r4/Coverage/cov015\",\n \"resource\": {\n \"resourceType\": \"Coverage\",\n \"id\": \"cov015\",\n \"meta\": {\n \"versionId\": \"1\",\n \"lastUpdated\": \"2024-05-08T09:47:16.618-04:00\",\n \"source\": \"#2oewiwPOJlURF4aM\"\n },\n \"status\": \"active\",\n \"subscriberId\": \"10A3D58WH456\",\n \"beneficiary\": {\n \"reference\": \"Patient/pat015\"\n },\n \"payor\": [\n {\n \"reference\": \"Organization/org1234\"\n }\n ],\n \"class\": [\n {\n \"type\": {\n \"coding\": [\n {\n \"system\": \"http://hl7.org/fhir/coverage-class\",\n \"code\": \"plan\"\n }\n ]\n },\n \"value\": \"Medicare Part A\"\n }\n ]\n },\n \"search\": {\n \"mode\": \"match\"\n }\n }\n ]\n },\n \"serviceRequestBundle\": {\n \"resourceType\": \"Bundle\",\n \"id\": \"016cc2fb-60ae-4796-9932-c49cc3d1b40c\",\n \"meta\": {\n \"lastUpdated\": \"2024-05-10T01:49:20.640+00:00\"\n },\n \"type\": \"searchset\",\n \"total\": 1,\n \"link\": [\n {\n \"relation\": \"self\",\n \"url\": \"https://inferno-qa.healthit.gov/reference-server/r4/ServiceRequest?_id=servreq-g0180-1&_include=ServiceRequest%3Apatient&_include=ServiceRequest%3Aperformer&_include=ServiceRequest%3Arequester&_include%3Aiterate=PractitionerRole%3Aorganization&_include%3Aiterate=PractitionerRole%3Apractitioner\"\n }\n ],\n \"entry\": [\n {\n \"fullUrl\": \"https://inferno-qa.healthit.gov/reference-server/r4/ServiceRequest/servreq-g0180-1\",\n \"resource\": {\n \"resourceType\": \"ServiceRequest\",\n \"id\": \"servreq-g0180-1\",\n \"meta\": {\n \"versionId\": \"1\",\n \"lastUpdated\": \"2024-04-25T17:48:34.382+00:00\",\n \"source\": \"#AylXJ4OQfExfrWTQ\"\n },\n \"identifier\": [\n {\n \"type\": {\n \"coding\": [\n {\n \"system\": \"http://terminology.hl7.org/CodeSystem/v2-0203\",\n \"code\": \"PLAC\"\n }\n ]\n },\n \"value\": \"11ddab7e-1488-4848-b3d5-512d2f1e3f28\"\n }\n ],\n \"status\": \"draft\",\n \"intent\": \"order\",\n \"code\": {\n \"coding\": [\n {\n \"system\": \"https://bluebutton.cms.gov/resources/codesystem/hcpcs\",\n \"code\": \"G0180\",\n \"display\": \"Medicare-covered home health services under a home health plan of care\"\n }\n ]\n },\n \"subject\": {\n \"reference\": \"Patient/pat015\"\n },\n \"occurrenceDateTime\": \"2017-10-01\",\n \"authoredOn\": \"2017-10-04\",\n \"requester\": {\n \"display\": \"Smythe Juliette, MD\"\n },\n \"performer\": [\n {\n \"reference\": \"Practitioner/pra1255\"\n }\n ],\n \"insurance\": [\n {\n \"reference\": \"Coverage/cov016\"\n }\n ]\n },\n \"search\": {\n \"mode\": \"match\"\n }\n },\n {\n \"fullUrl\": \"https://inferno-qa.healthit.gov/reference-server/r4/Patient/pat015\",\n \"resource\": {\n \"resourceType\": \"Patient\",\n \"id\": \"pat015\",\n \"meta\": {\n \"versionId\": \"1\",\n \"lastUpdated\": \"2024-04-25T17:48:34.068+00:00\",\n \"source\": \"#fT6fENtjF2A8Ne61\"\n },\n \"text\": {\n \"status\": \"generated\",\n \"div\": \"
William Hale Oster OSTER
Identifier0M34355006FW
Address202 Burlington Road
Bedford MA
Date of birth23 February 2015
\"\n },\n \"identifier\": [\n {\n \"use\": \"usual\",\n \"type\": {\n \"coding\": [\n {\n \"system\": \"http://terminology.hl7.org/CodeSystem/v2-0203\",\n \"code\": \"MR\"\n }\n ],\n \"text\": \"Medical Record Number\"\n },\n \"system\": \"http://hl7.org/fhir/sid/us-medicare\",\n \"value\": \"0M34355006FW\"\n }\n ],\n \"name\": [\n {\n \"use\": \"official\",\n \"family\": \"Oster\",\n \"given\": [\n \"William\",\n \"Hale\",\n \"Oster\"\n ]\n }\n ],\n \"telecom\": [\n {\n \"system\": \"phone\",\n \"value\": \"(781) 555-5555\",\n \"use\": \"home\",\n \"rank\": 1\n },\n {\n \"system\": \"phone\",\n \"value\": \"(781) 555 5613\",\n \"use\": \"work\",\n \"rank\": 2\n },\n {\n \"system\": \"phone\",\n \"value\": \"(781) 555 8834\",\n \"use\": \"old\",\n \"period\": {\n \"end\": \"2014\"\n }\n }\n ],\n \"gender\": \"male\",\n \"birthDate\": \"2015-02-23\",\n \"address\": [\n {\n \"use\": \"home\",\n \"type\": \"both\",\n \"line\": [\n \"202 Burlington Road\"\n ],\n \"city\": \"Bedford\",\n \"state\": \"MA\",\n \"postalCode\": \"01730\"\n }\n ]\n },\n \"search\": {\n \"mode\": \"include\"\n }\n }\n ]\n }\n }\n }\n]\n", + "description": "Provide a list of request bodies send multiple requests. e.g. [json_body_1, json_body_2]", + "title": "Request bodies collection to use to invoke the `order-sign` hook", + "type": "textarea", + "value": "[\n {\n \"hookInstance\": \"d1577c69-dfbe-44ad-ba6d-3e05e953b2ea\",\n \"fhirServer\": \"https://inferno-qa.healthit.gov/reference-server/r4\",\n \"hook\": \"order-sign\",\n \"fhirAuthorization\": {\n \"access_token\": \"SAMPLE_TOKEN\",\n \"token_type\": \"Bearer\",\n \"expires_in\": 300,\n \"scope\": \"patient/Patient.read patient/Observation.read\",\n \"subject\": \"cds-service4\"\n },\n \"context\": {\n \"userId\": \"Practitioner/pra1234\",\n \"patientId\": \"pat015\",\n \"encounterId\": \"enc-pat014\",\n \"draftOrders\": {\n \"resourceType\": \"Bundle\",\n \"entry\": [\n {\n \"resource\": {\n \"resourceType\": \"DeviceRequest\",\n \"id\": \"devreq-015-e0250\",\n \"meta\": {\n \"versionId\": \"1\",\n \"lastUpdated\": \"2024-05-08T09:47:16.992-04:00\",\n \"source\": \"#Odh5ejWjud85tvNJ\",\n \"profile\": [\n \"http://hl7.org/fhir/us/davinci-crd/R4/StructureDefinition/profile-devicerequest-r4\"\n ]\n },\n \"identifier\": [\n {\n \"type\": {\n \"coding\": [\n {\n \"system\": \"http://terminology.hl7.org/CodeSystem/v2-0203\",\n \"code\": \"PLAC\"\n }\n ]\n },\n \"value\": \"f105372f-bbef-442c-ad7a-708fee7f8c93\"\n }\n ],\n \"status\": \"draft\",\n \"intent\": \"original-order\",\n \"codeCodeableConcept\": {\n \"coding\": [\n {\n \"system\": \"https://bluebutton.cms.gov/resources/codesystem/hcpcs\",\n \"code\": \"E0250\",\n \"display\": \"Hospital bed fixed height with any type of side rails, mattress\"\n }\n ]\n },\n \"subject\": {\n \"reference\": \"Patient/pat015\"\n },\n \"authoredOn\": \"2023-01-01T00:00:00Z\",\n \"requester\": {\n \"reference\": \"Practitioner/pra-hfairchild\"\n },\n \"performer\": {\n \"reference\": \"Practitioner/pra1234\"\n },\n \"insurance\": [\n {\n \"reference\": \"Coverage/cov015\"\n }\n ]\n }\n }\n ]\n }\n },\n \"extension\": {\n \"davinci-crd.configuration\": {\n \"alt-drug\": true\n }\n },\n \"prefetch\": {\n \"coverageBundle\": {\n \"resourceType\": \"Bundle\",\n \"type\": \"collection\",\n \"entry\": [\n {\n \"fullUrl\": \"https://inferno-qa.healthit.gov/reference-server/r4/Coverage/cov015\",\n \"resource\": {\n \"resourceType\": \"Coverage\",\n \"id\": \"cov015\",\n \"meta\": {\n \"versionId\": \"1\",\n \"lastUpdated\": \"2024-05-08T09:47:16.618-04:00\",\n \"source\": \"#2oewiwPOJlURF4aM\"\n },\n \"status\": \"active\",\n \"subscriberId\": \"10A3D58WH456\",\n \"beneficiary\": {\n \"reference\": \"Patient/pat015\"\n },\n \"payor\": [\n {\n \"reference\": \"Organization/org1234\"\n }\n ],\n \"class\": [\n {\n \"type\": {\n \"coding\": [\n {\n \"system\": \"http://hl7.org/fhir/coverage-class\",\n \"code\": \"plan\"\n }\n ]\n },\n \"value\": \"Medicare Part A\"\n }\n ]\n },\n \"search\": {\n \"mode\": \"match\"\n }\n }\n ]\n },\n \"deviceRequestBundle\": {\n \"resourceType\": \"Bundle\",\n \"type\": \"collection\",\n \"entry\": [\n {\n \"fullUrl\": \"https://inferno-qa.healthit.gov/reference-server/r4/DeviceRequest/devreq-015-e0250\",\n \"resource\": {\n \"resourceType\": \"DeviceRequest\",\n \"id\": \"devreq-015-e0250\",\n \"meta\": {\n \"versionId\": \"1\",\n \"lastUpdated\": \"2024-05-08T09:47:16.992-04:00\",\n \"source\": \"#Odh5ejWjud85tvNJ\",\n \"profile\": [\n \"http://hl7.org/fhir/us/davinci-crd/R4/StructureDefinition/profile-devicerequest-r4\"\n ]\n },\n \"identifier\": [\n {\n \"type\": {\n \"coding\": [\n {\n \"system\": \"http://terminology.hl7.org/CodeSystem/v2-0203\",\n \"code\": \"PLAC\"\n }\n ]\n },\n \"value\": \"f105372f-bbef-442c-ad7a-708fee7f8c93\"\n }\n ],\n \"status\": \"draft\",\n \"intent\": \"original-order\",\n \"codeCodeableConcept\": {\n \"coding\": [\n {\n \"system\": \"https://bluebutton.cms.gov/resources/codesystem/hcpcs\",\n \"code\": \"E0250\",\n \"display\": \"Hospital bed fixed height with any type of side rails, mattress\"\n }\n ]\n },\n \"subject\": {\n \"reference\": \"Patient/pat015\"\n },\n \"authoredOn\": \"2023-01-01T00:00:00Z\",\n \"requester\": {\n \"reference\": \"Practitioner/pra-hfairchild\"\n },\n \"performer\": {\n \"reference\": \"Practitioner/pra1234\"\n },\n \"insurance\": [\n {\n \"reference\": \"Coverage/cov015\"\n }\n ]\n },\n \"search\": {\n \"mode\": \"match\"\n }\n },\n {\n \"fullUrl\": \"https://inferno-qa.healthit.gov/reference-server/r4/Patient/pat015\",\n \"resource\": {\n \"resourceType\": \"Patient\",\n \"id\": \"pat015\",\n \"meta\": {\n \"versionId\": \"1\",\n \"lastUpdated\": \"2024-05-08T09:47:16.555-04:00\",\n \"source\": \"#0mrTfQICzelSBeef\"\n },\n \"text\": {\n \"status\": \"generated\",\n \"div\": \"
William Hale Oster OSTER
Identifier0M34355006FW
Address202 Burlington Road
Bedford MA
Date of birth23 February 2015
\"\n },\n \"identifier\": [\n {\n \"use\": \"usual\",\n \"type\": {\n \"coding\": [\n {\n \"system\": \"http://terminology.hl7.org/CodeSystem/v2-0203\",\n \"code\": \"MR\"\n }\n ],\n \"text\": \"Medical Record Number\"\n },\n \"system\": \"http://hl7.org/fhir/sid/us-medicare\",\n \"value\": \"0M34355006FW\"\n }\n ],\n \"name\": [\n {\n \"use\": \"official\",\n \"family\": \"Oster\",\n \"given\": [\n \"William\",\n \"Hale\",\n \"Oster\"\n ]\n }\n ],\n \"telecom\": [\n {\n \"system\": \"phone\",\n \"value\": \"(781) 555-5555\",\n \"use\": \"home\",\n \"rank\": 1\n },\n {\n \"system\": \"phone\",\n \"value\": \"(781) 555 5613\",\n \"use\": \"work\",\n \"rank\": 2\n },\n {\n \"system\": \"phone\",\n \"value\": \"(781) 555 8834\",\n \"use\": \"old\",\n \"period\": {\n \"end\": \"2014\"\n }\n }\n ],\n \"gender\": \"male\",\n \"birthDate\": \"2015-02-23\",\n \"address\": [\n {\n \"use\": \"home\",\n \"type\": \"both\",\n \"line\": [\n \"202 Burlington Road\"\n ],\n \"city\": \"Bedford\",\n \"state\": \"MA\",\n \"postalCode\": \"01730\"\n }\n ]\n },\n \"search\": {\n \"mode\": \"include\"\n }\n },\n {\n \"fullUrl\": \"https://inferno-qa.healthit.gov/reference-server/r4/Practitioner/pra1234\",\n \"resource\": {\n \"resourceType\": \"Practitioner\",\n \"id\": \"pra1234\",\n \"meta\": {\n \"versionId\": \"1\",\n \"lastUpdated\": \"2024-05-08T09:47:15.668-04:00\",\n \"source\": \"#YCao6W9MbpwL2D8L\",\n \"profile\": [\n \"http://hl7.org/fhir/us/core/StructureDefinition/us-core-practitioner\"\n ]\n },\n \"identifier\": [\n {\n \"system\": \"http://hl7.org/fhir/sid/us-npi\",\n \"value\": \"1122334455\"\n }\n ],\n \"name\": [\n {\n \"use\": \"official\",\n \"family\": \"Doe\",\n \"given\": [\n \"Jane\",\n \"Betty\"\n ],\n \"prefix\": [\n \"Dr.\"\n ]\n }\n ],\n \"telecom\": [\n {\n \"system\": \"phone\",\n \"value\": \"716-873-1557\"\n },\n {\n \"system\": \"email\",\n \"value\": \"jane.betty@myhospital.com\"\n }\n ],\n \"address\": [\n {\n \"use\": \"home\",\n \"type\": \"both\",\n \"line\": [\n \"840 Seneca St\"\n ],\n \"city\": \"Buffalo\",\n \"state\": \"NY\",\n \"postalCode\": \"14210\"\n }\n ],\n \"qualification\": [\n {\n \"identifier\": [\n {\n \"system\": \"http://example.org/UniversityIdentifier\",\n \"value\": \"12345\"\n }\n ],\n \"code\": {\n \"coding\": [\n {\n \"system\": \"http://terminology.hl7.org/CodeSystem/v2-0360/2.7\",\n \"code\": \"MD\",\n \"display\": \"Doctor of Medicine\"\n }\n ],\n \"text\": \"Doctor of Medicine\"\n },\n \"period\": {\n \"start\": \"1995\"\n },\n \"issuer\": {\n \"display\": \"Example University\"\n }\n }\n ]\n },\n \"search\": {\n \"mode\": \"include\"\n }\n },\n {\n \"fullUrl\": \"https://inferno-qa.healthit.gov/reference-server/r4/Practitioner/pra-hfairchild\",\n \"resource\": {\n \"resourceType\": \"Practitioner\",\n \"id\": \"pra-hfairchild\",\n \"meta\": {\n \"versionId\": \"1\",\n \"lastUpdated\": \"2024-05-08T09:47:16.289-04:00\",\n \"source\": \"#z4T7ZRvSCFEfONs9\"\n },\n \"identifier\": [\n {\n \"system\": \"http://hl7.org/fhir/sid/us-npi\",\n \"value\": \"1122334467\"\n }\n ],\n \"name\": [\n {\n \"use\": \"official\",\n \"family\": \"Fairchild\",\n \"given\": [\n \"Helen\"\n ],\n \"prefix\": [\n \"Ms.\"\n ]\n }\n ]\n },\n \"search\": {\n \"mode\": \"include\"\n }\n }\n ]\n }\n }\n },\n {\n \"hookInstance\": \"d1577c69-dfbe-44ad-ba6d-3e05e953b2ea\",\n \"fhirServer\": \"https://inferno-qa.healthit.gov/reference-server/r4\",\n \"hook\": \"order-sign\",\n \"fhirAuthorization\": {\n \"access_token\": \"SAMPLE_TOKEN\",\n \"token_type\": \"Bearer\",\n \"expires_in\": 300,\n \"scope\": \"patient/Patient.read patient/Observation.read\",\n \"subject\": \"cds-service4\"\n },\n \"context\": {\n \"userId\": \"Practitioner/pra1255\",\n \"patientId\": \"pat015\",\n \"encounterId\": \"enc-pat014\",\n \"draftOrders\": {\n \"resourceType\": \"Bundle\",\n \"entry\": [\n {\n \"resource\": {\n \"resourceType\": \"ServiceRequest\",\n \"id\": \"servreq-g0180-1\",\n \"meta\": {\n \"versionId\": \"1\",\n \"lastUpdated\": \"2024-04-25T17:48:34.382+00:00\",\n \"source\": \"#AylXJ4OQfExfrWTQ\"\n },\n \"identifier\": [\n {\n \"type\": {\n \"coding\": [\n {\n \"system\": \"http://terminology.hl7.org/CodeSystem/v2-0203\",\n \"code\": \"PLAC\"\n }\n ]\n },\n \"value\": \"11ddab7e-1488-4848-b3d5-512d2f1e3f28\"\n }\n ],\n \"status\": \"draft\",\n \"intent\": \"order\",\n \"code\": {\n \"coding\": [\n {\n \"system\": \"https://bluebutton.cms.gov/resources/codesystem/hcpcs\",\n \"code\": \"G0180\",\n \"display\": \"Medicare-covered home health services under a home health plan of care\"\n }\n ]\n },\n \"subject\": {\n \"reference\": \"Patient/pat015\"\n },\n \"occurrenceDateTime\": \"2017-10-01\",\n \"authoredOn\": \"2017-10-04\",\n \"requester\": {\n \"display\": \"Smythe Juliette, MD\"\n },\n \"performer\": [\n {\n \"reference\": \"Practitioner/pra1255\"\n }\n ],\n \"insurance\": [\n {\n \"reference\": \"Coverage/cov015\"\n }\n ]\n }\n }\n ]\n }\n },\n \"extension\": {\n \"davinci-crd.configuration\": {\n \"alt-drug\": true,\n \"coverage\": true,\n \"dtr-clin\": true,\n \"max-cards\": 10\n }\n },\n \"prefetch\": {\n \"coverageBundle\": {\n \"resourceType\": \"Bundle\",\n \"type\": \"collection\",\n \"entry\": [\n {\n \"fullUrl\": \"https://inferno-qa.healthit.gov/reference-server/r4/Coverage/cov015\",\n \"resource\": {\n \"resourceType\": \"Coverage\",\n \"id\": \"cov015\",\n \"meta\": {\n \"versionId\": \"1\",\n \"lastUpdated\": \"2024-05-08T09:47:16.618-04:00\",\n \"source\": \"#2oewiwPOJlURF4aM\"\n },\n \"status\": \"active\",\n \"subscriberId\": \"10A3D58WH456\",\n \"beneficiary\": {\n \"reference\": \"Patient/pat015\"\n },\n \"payor\": [\n {\n \"reference\": \"Organization/org1234\"\n }\n ],\n \"class\": [\n {\n \"type\": {\n \"coding\": [\n {\n \"system\": \"http://hl7.org/fhir/coverage-class\",\n \"code\": \"plan\"\n }\n ]\n },\n \"value\": \"Medicare Part A\"\n }\n ]\n },\n \"search\": {\n \"mode\": \"match\"\n }\n }\n ]\n },\n \"serviceRequestBundle\": {\n \"resourceType\": \"Bundle\",\n \"id\": \"016cc2fb-60ae-4796-9932-c49cc3d1b40c\",\n \"meta\": {\n \"lastUpdated\": \"2024-05-10T01:49:20.640+00:00\"\n },\n \"type\": \"searchset\",\n \"total\": 1,\n \"link\": [\n {\n \"relation\": \"self\",\n \"url\": \"https://inferno-qa.healthit.gov/reference-server/r4/ServiceRequest?_id=servreq-g0180-1&_include=ServiceRequest%3Apatient&_include=ServiceRequest%3Aperformer&_include=ServiceRequest%3Arequester&_include%3Aiterate=PractitionerRole%3Aorganization&_include%3Aiterate=PractitionerRole%3Apractitioner\"\n }\n ],\n \"entry\": [\n {\n \"fullUrl\": \"https://inferno-qa.healthit.gov/reference-server/r4/ServiceRequest/servreq-g0180-1\",\n \"resource\": {\n \"resourceType\": \"ServiceRequest\",\n \"id\": \"servreq-g0180-1\",\n \"meta\": {\n \"versionId\": \"1\",\n \"lastUpdated\": \"2024-04-25T17:48:34.382+00:00\",\n \"source\": \"#AylXJ4OQfExfrWTQ\"\n },\n \"identifier\": [\n {\n \"type\": {\n \"coding\": [\n {\n \"system\": \"http://terminology.hl7.org/CodeSystem/v2-0203\",\n \"code\": \"PLAC\"\n }\n ]\n },\n \"value\": \"11ddab7e-1488-4848-b3d5-512d2f1e3f28\"\n }\n ],\n \"status\": \"draft\",\n \"intent\": \"order\",\n \"code\": {\n \"coding\": [\n {\n \"system\": \"https://bluebutton.cms.gov/resources/codesystem/hcpcs\",\n \"code\": \"G0180\",\n \"display\": \"Medicare-covered home health services under a home health plan of care\"\n }\n ]\n },\n \"subject\": {\n \"reference\": \"Patient/pat015\"\n },\n \"occurrenceDateTime\": \"2017-10-01\",\n \"authoredOn\": \"2017-10-04\",\n \"requester\": {\n \"display\": \"Smythe Juliette, MD\"\n },\n \"performer\": [\n {\n \"reference\": \"Practitioner/pra1255\"\n }\n ],\n \"insurance\": [\n {\n \"reference\": \"Coverage/cov016\"\n }\n ]\n },\n \"search\": {\n \"mode\": \"match\"\n }\n },\n {\n \"fullUrl\": \"https://inferno-qa.healthit.gov/reference-server/r4/Patient/pat015\",\n \"resource\": {\n \"resourceType\": \"Patient\",\n \"id\": \"pat015\",\n \"meta\": {\n \"versionId\": \"1\",\n \"lastUpdated\": \"2024-04-25T17:48:34.068+00:00\",\n \"source\": \"#fT6fENtjF2A8Ne61\"\n },\n \"text\": {\n \"status\": \"generated\",\n \"div\": \"
William Hale Oster OSTER
Identifier0M34355006FW
Address202 Burlington Road
Bedford MA
Date of birth23 February 2015
\"\n },\n \"identifier\": [\n {\n \"use\": \"usual\",\n \"type\": {\n \"coding\": [\n {\n \"system\": \"http://terminology.hl7.org/CodeSystem/v2-0203\",\n \"code\": \"MR\"\n }\n ],\n \"text\": \"Medical Record Number\"\n },\n \"system\": \"http://hl7.org/fhir/sid/us-medicare\",\n \"value\": \"0M34355006FW\"\n }\n ],\n \"name\": [\n {\n \"use\": \"official\",\n \"family\": \"Oster\",\n \"given\": [\n \"William\",\n \"Hale\",\n \"Oster\"\n ]\n }\n ],\n \"telecom\": [\n {\n \"system\": \"phone\",\n \"value\": \"(781) 555-5555\",\n \"use\": \"home\",\n \"rank\": 1\n },\n {\n \"system\": \"phone\",\n \"value\": \"(781) 555 5613\",\n \"use\": \"work\",\n \"rank\": 2\n },\n {\n \"system\": \"phone\",\n \"value\": \"(781) 555 8834\",\n \"use\": \"old\",\n \"period\": {\n \"end\": \"2014\"\n }\n }\n ],\n \"gender\": \"male\",\n \"birthDate\": \"2015-02-23\",\n \"address\": [\n {\n \"use\": \"home\",\n \"type\": \"both\",\n \"line\": [\n \"202 Burlington Road\"\n ],\n \"city\": \"Bedford\",\n \"state\": \"MA\",\n \"postalCode\": \"01730\"\n }\n ]\n },\n \"search\": {\n \"mode\": \"include\"\n }\n }\n ]\n }\n }\n }\n]\n" + } + ] +} diff --git a/config/puma.rb b/config/puma.rb new file mode 100644 index 0000000..f3026ab --- /dev/null +++ b/config/puma.rb @@ -0,0 +1,2 @@ +environment ENV.fetch('APP_ENV', 'development') +port ENV.fetch('INFERNO_PORT', 4567) diff --git a/data/.keep b/data/.keep new file mode 100644 index 0000000..e69de29 diff --git a/data/redis/.keep b/data/redis/.keep new file mode 100644 index 0000000..e69de29 diff --git a/davinci_crd_test_kit.gemspec b/davinci_crd_test_kit.gemspec new file mode 100644 index 0000000..bbcb42c --- /dev/null +++ b/davinci_crd_test_kit.gemspec @@ -0,0 +1,25 @@ +require_relative 'lib/davinci_crd_test_kit/version' + +Gem::Specification.new do |spec| + spec.name = 'davinci_crd_test_kit' + spec.version = DaVinciCRDTestKit::VERSION + spec.authors = ['Stephen MacVicar', 'Vanessa Fotso', 'Emily Michaud'] + spec.email = ['inferno@groups.mitre.org'] + spec.summary = 'DaVinci CRD Test Kit' + spec.description = 'DaVinci CRD Test Kit' + spec.homepage = 'https://github.com/inferno-framework/davinci-crd-test-kit' + spec.license = 'Apache-2.0' + spec.add_runtime_dependency 'inferno_core', '~> 0.4.37' + spec.add_runtime_dependency 'smart_app_launch_test_kit', '~> 0.4.1' + spec.add_runtime_dependency 'tls_test_kit', '~> 0.2.1' + spec.required_ruby_version = Gem::Requirement.new('>= 3.1.2') + spec.metadata['homepage_uri'] = spec.homepage + spec.metadata['source_code_uri'] = spec.homepage + spec.files = [ + Dir['lib/**/*.rb'], + Dir['lib/**/*.json'], + 'LICENSE' + ].flatten + + spec.require_paths = ['lib'] +end diff --git a/docker-compose.background.yml b/docker-compose.background.yml new file mode 100644 index 0000000..a9e169d --- /dev/null +++ b/docker-compose.background.yml @@ -0,0 +1,36 @@ +version: '3' +services: + fhir_validator_app: + image: infernocommunity/fhir-validator-app + depends_on: + - hl7_validator_service + environment: + EXTERNAL_VALIDATOR_URL: http://localhost/validatorapi + VALIDATOR_BASE_PATH: /validator + nginx: + image: nginx + volumes: + - ./config/nginx.background.conf:/etc/nginx/nginx.conf + ports: + - "80:80" + command: [nginx, '-g', 'daemon off;'] + depends_on: + - fhir_validator_app + redis: + image: redis + ports: + - "6379:6379" + volumes: + - ./data/redis:/data + command: redis-server --appendonly yes + hl7_validator_service: + image: infernocommunity/inferno-resource-validator:1.0.52 + environment: + # Defines how long validator sessions last if unused, in minutes: + # Negative values mean sessions never expire, 0 means sessions immediately expire + SESSION_CACHE_DURATION: -1 + volumes: + - ./lib/davinci_crd_test_kit/igs:/home/igs + # To let the service share your local FHIR package cache, + # uncomment the below line + # - ~/.fhir:/home/ktor/.fhir diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..edf8748 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,35 @@ +version: '3' +services: + inferno: + build: + context: ./ + volumes: + - ./data:/opt/inferno/data + depends_on: + - hl7_validator_service + worker: + build: + context: ./ + volumes: + - ./data:/opt/inferno/data + command: bundle exec sidekiq -r ./worker.rb + depends_on: + - redis + fhir_validator_app: + extends: + file: docker-compose.background.yml + service: fhir_validator_app + nginx: + extends: + file: docker-compose.background.yml + service: nginx + volumes: + - ./config/nginx.conf:/etc/nginx/nginx.conf + redis: + extends: + file: docker-compose.background.yml + service: redis + hl7_validator_service: + extends: + file: docker-compose.background.yml + service: hl7_validator_service diff --git a/docs/crd-client-shalls.md b/docs/crd-client-shalls.md new file mode 100644 index 0000000..cce6c95 --- /dev/null +++ b/docs/crd-client-shalls.md @@ -0,0 +1,165 @@ + +# 5.1.3 +* if the client maintains the data element and surfaces it to users, then it + SHALL be exposed in their FHIR interface when the data exists and privacy + constraints permit. + +# 5.1.5 +* all US Core profiles are deemed to be part of this IG and available for use in + CRD communications. + +# 5.6 +* All CRD clients will need to be configured to support communicating to a + particular CRD server. + * **QUESTION:** What will be required for Inferno to be recognized as a CRD + server? + +# 5.8.1 +* CRD clients supporting prefetch SHALL inspect the CDS Hooks Discovery Endpoint + to determine exact prefetch key names and queries. + +# 5.9 +* SHALL support the SMART on FHIR interface +* SHALL allow launching of SMART apps from within their application +* SHALL be capable of providing the SMART app access to information it exposes + to CRD Servers using the CDS Hooks interface +* In the specific case of order-based hooks, “What if” SHOULD use the Order Sign + hook, but SHALL use the configuration option that prevents the return of an + unsolicited determination and MAY use configuration options to prevent the + return of other irrelevant types of cards (e.g. duplicate therapy, etc.) + +# 5.10 +* When CRD clients pass resources to a CRD as part of context, the resources + SHALL have an id and that id SHALL be usable as a target for references +* SHALL retain logs of all CRD-related hook invocations and their responses for + access in the event of a dispute +* Organizations SHALL have processes to ensure logs can be accessed by + appropriate authorized users to help resolve discrepancies or issues in a + timely manner. + +# 6 +* Implementers SHALL adhere to any security and privacy rules defined by:... +* communications between CRD Clients and CRD Servers SHALL use TLS. +* SHALL support running applications that adhere to the SMART on FHIR + confidential app profile. +* SHALL ensure that the resource identifiers exposed over the CRD interface are + distinct from and have no determinable relationship with any business + identifiers associated with those records + +# 7.5.1 +* **If they support it:** CRD Clients SHALL convey configuration options when + invoking the hook using the davinci-crd.configuration extension. + +# 7.6 +* **NOTE:** clients not required to support prefetch +* where a hook defines a context element that consists of a resource or + collection of resources (e.g. order-select.draftOrders or + order-sign.draftOrders), systems SHALL recognize context tokens of the form + context...id in prefetch queries. +* Those tokens SHALL evaluate to a comma-separated list of the identifiers of + all resources of the specified type within that context key. + +# 7.7.2 +* the inclusion of the id element in ‘created’ resources and references in + created and updated resources within multi-action suggestions SHALL be handled + as per FHIR’s transaction processing rules +* Specifically, this means that if a FHIR Reference points to the resource type + and identifier of a resource of another ‘create’ Action in the same + Suggestion, then the reference to that resource SHALL be updated by the server + to point to the identifier assigned by the client when performing the ‘create’ +* CRD Clients SHALL perform ‘creates’ in an order that ensures that referenced + resources are created prior to referencing resources + +# 7.9 +* Provider systems SHALL only invoke hooks on payer services where the patient + record indicates active coverage with the payer associated with the service. +* where a patient has multiple active coverages that could be relevant to the + current order/appointment/etc., CRD clients SHALL select from those coverages + which is most likely to be primary and only solicit coverage information for + that one payer +* If they invoke CRD on other payers, CRD clients SHALL ensure that card types + that return coverage information are disabled for those ‘likely secondary’ + payers +* Where the patient has multiple active coverages that the CRD client deems + appropriate to call the respective CRD servers for, the CRD client SHALL + invoke all CRD server calls in parallel and display results simultaneously to + ensure timely response to user action. + +# 8 +* CRD Clients conforming to this implementation guide SHALL support at least one + of the hooks and (for order-centric hooks), at least one of the order resource + types listed below + +# 8.2, 8.5, 8.7 +* CRD clients and servers SHALL, at minimum, support returning and processing + the Coverage Information system action for all invocations of this hook. + +# 9.1 +* conformant CRD Clients SHALL support the External Reference, Instructions, and + Coverage Information responses and SHOULD support the remaining types. +* When a Coverage Information card type indicating that additional clinical + documentation is needed and the CRD client supports DTR, CRD Clients SHALL + ensure that clinical users have an opportunity to launch the DTR app as part + of the current workflow. + +# 9.4 +* CRD clients and services SHALL support the new CDS Hooks system action + functionality to cause annotations to automatically be stored on the relevant + request, appointment, etc. without any user intervention +* In this case, the discrete information propagated into the order extension + SHALL be available to the user for viewing + +# 9.7 +* Instead of using a card, CRD services MAY opt to use a systemAction instead. + CRD clients supporting this card type SHALL support either approach. + +# 9.8 +* Instead of using a card, CRD services MAY opt to use a systemAction instead. + CRD clients supporting this card type SHALL support either approach + +# 10.1 +* Clients that perform such suppression of messages SHALL mitigate this + potential for misinterpretation. + +# 12.0.1 +* For this implementation guide, Must Support means that CRD Clients must be + capable of exposing the data to at least some CRD Servers. + +# 12.1.1.1 +* In addition to the U.S. core expectations, the CRD Client SHALL support all + ‘SHOULD’ ‘read’ and ‘search’ capabilities listed below for resources + referenced in supported hooks and order types if it does not support returning + the associated resources as part of CDS Hooks pre-fetch. +* The CRD Client SHALL also support ‘update’ functionality for all resources + listed below where the client allows invoking hooks based on the resource. + +# 12.1.4.1.1 +* CRD Clients SHALL use this profile to provide appointments context objects to + CRD Servers when invoking the appointment-book hook as well as to resolve + other references to Appointment resources. + +# 12.1.5.1.1 +* CRD Clients SHALL use this profile to resolve references to + CommunicationRequest resources passed to CRD Servers (e.g. selections context + references) and to populate draftOrders context objects when invoking the when + invoking the following CDS Hooks: + +# 12.1.6.1.1 +* CRD Clients SHALL use this profile to resolve references to insurance Coverage + resources passed to CRD Servers. + +# 12.1.7.1.1 +* CRD Clients SHALL use this profile to resolve references to Device resources + passed to CRD Servers. + +# 12.1.8.1.1 +* CRD Clients SHALL use this profile to resolve references to DeviceRequest + resources passed to CRD Servers (e.g. selections context references) and to + populate draftOrders context objects when invoking the following CDS Hooks: + +# 12.1.9.1.1 +* CRD Clients SHALL use this profile to resolve references to Encounter + resources passed to CRD Servers, including encounterId context references when + invoking the following CDS Hooks: + +etc. diff --git a/docs/crd-server-shalls.md b/docs/crd-server-shalls.md new file mode 100644 index 0000000..dce9b09 --- /dev/null +++ b/docs/crd-server-shalls.md @@ -0,0 +1,169 @@ + +# 1.2 +* for the purpose of CRD conformance, payers SHALL have a single endpoint + (managed by themselves or a delegate) that can handle responding to all CRD + service calls. + +# 5.1.3 +* the server SHALL leverage mustSupport elements as available and appropriate to + provide decision support. + +# 5.2 +* CRD services SHALL return responses for all supported hooks and SHALL respond + within the required duration 90% of the time...For most hooks, this target + time is 5 seconds. It extends to 10 seconds for Appointment Book and for Order + Dispatch and Order Sign hooks that are sent at least 24 hours after the last + hook invocation for the same order(s) because there is no opportunity to cache + data in those cases. + +# 5.3 +* CDS services SHALL ensure that the guidance returned with respect to coverage + and prior authorizations (e.g. assertions that a service is covered, or prior + authorization is not necessary) is as accurate as guidance that would be + provided by other means (e.g. portals, phone calls). + +# 5.5 +* Payers and service providers SHALL ensure that CDS Hooks return only messages + and information relevant and useful to the intended recipient. + +# 5.8 +* each payer will define the prefetch requests for their CRD Server based on the + information they require to provide coverage requirements + +# 5.8.3 +* The queries use the defined search parameter names from the respective FHIR + specification versions. If parties processing these queries have varied from + these ‘standard’ search parameter names (as indicated by navigating their + CapabilityStatements), the CRD Server will be responsible for translating the + parameters into the CRD client’s local names. For example, if a particular CRD + client’s CapabilityStatement indicates that the parameter name (that + corresponds to HL7’s ‘encounter’ search criteria) is named ‘visit’ on the + client’s server, the Service will have to construct its search URL + accordingly. +* CRD Servers SHALL provide what coverage requirements they can based on the + information available. + +# 5.10 +* SHALL retain logs of all CRD-related hook invocations and their responses for + access in the event of a dispute +* All Card.suggestion elements SHALL populate the Suggestion.uuid element to aid + in log reconciliation +* Organizations SHALL have processes to ensure logs can be accessed by + appropriate authorized users to help resolve discrepancies or issues in a + timely manner. + +# 6 +* Implementers SHALL adhere to any security and privacy rules defined by:... +* communications between CRD Clients and CRD Servers SHALL use TLS. +* SHALL use information received solely for coverage determination and decision + support purposes +* SHALL NOT retain data received over the CRD interfaces for any purpose other + than audit or providing context for form completion using DTR. + +# 7.5 +* Each option SHALL include four mandatory elements... +* A default value SHALL also be provided to show users what to expect when an + override is not specified. +* SHALL, at minimum, offer configuration options for each type of card they support +* payer services SHALL gracefully handle disallowed/nonsensical combinations +* Codes SHALL be valid JSON property names and SHALL come from the CRD Card + Types list if an applicable type is in that list +* Codes, names, and descriptions SHALL be unique within a CDS Service definition + +# 7.5.1 +* CRD Servers SHALL behave in the manner prescribed by any supported + configuration information received from the CRD Client. +* CRD Servers SHALL NOT require the inclusion of configuration information in a + hook call +* the CRD Server SHALL ignore the unsupported configuration information. + +# 7.7 +* the payer service SHALL query to determine if the client has a copy of the + Questionnaire before sending the request + +# 7.8 +* If a hook service is invoked on a collection of resources, all cards returned + that are specific to only a subset of the resources passed as context SHALL + disambiguate in the detail element which resources they’re associated with in + a human-friendly way + +# 8 +* CRD Servers conforming to this implementation guide SHALL provide a service + for all hooks and order resource types required of CRD clients by this + implementation guide unless the server has determined that the hook will not + be reasonably useful in determining coverage or documentation expectations for + the types of coverage provided. +* If the CRD Server encounters an error when processing the request, the system + SHALL return an appropriate error HTTP Response Code, starting with the digit + “4” or “5”, indicating that there was an error. +* While any 4xx or 5xx response code could be raised, the CRD Server SHALL use + the 400 and 422 codes in a manner consistent with the FHIR RESTful Create + Action + +# 8.1 +* The ‘primary’ hooks are Appointment Book, Orders Sign, and Order Dispatch. CRD + Servers SHALL, at minimum, return a Coverage Information system action for + these hooks, even if the response indicates that further information is needed + or that the level of detail provided is insufficient to determine coverage. +* The ‘secondary’ hooks are Orders Select, Encounter Start, and Encounter + Discharge... If Coverage Information is returned for these hooks, it SHALL NOT + include messages indicating a need for clinical or administrative information +* CRD Servers SHALL handle unrecognized context elements by ignoring them. + +# 8.2, 8.5, 8.7 +* CRD clients and servers SHALL, at minimum, support returning and processing + the Coverage Information system action for all invocations of this hook. + +# 9 +* Card.source.topic SHALL be populated, and has an extensible binding to the + ValueSet CRD Card Types. + +# 9.1 +* CRD Servers SHALL, at minimum, demonstrate an ability to return cards with the + following type: Coverage, External Reference and Instructions card types (card + type code documentation). +* CRD Servers that provide decision support for non-coverage/documentation areas + SHALL check that the CRD client does not have the information within its store + that would allow it to detect the issue itself. + +# 9.2 +* The card SHALL have at least one Card.link +* The Link.type SHALL have a type of “absolute”. + +# 9.4 +* In some cases, the answer might differ depending on factors such as in/out of + network, when the service is delivered, etc. These qualifiers around when the + coverage assertion is considered valid SHALL be included as part of the + annotation. +* If a CRD client submits a claim related to an order for which it has received + a coverage-information extension for the coverage type associated with the + claim, that claim SHALL include the coverage-assertion-id and, if applicable, + the satisfied-pa-id in the X12 837 K3 segment +* If multiple extension repetitions are present, all repetitions referencing + differing insurance (coverage-information.coverage) SHALL have distinct + coverage-assertion-ids and satisfied-pa-ids (if present) +* Where multiple repetions apply to the same coverage, they *SHALL have the same + coverage-assertion-ids and satisfied-pa-ids (if present) +* payers SHALL NOT send a system action to update the order unless something is + new +* When using this response type, the proposed order or appointment being updated + SHALL comply with the following profiles: ... +* CRD clients and services SHALL support the new CDS Hooks system action + functionality to cause annotations to automatically be stored on the relevant + request, appointment, etc. without any user intervention + +# 9.5 +* When using this response type, the proposed orders (and any associated + resources) SHALL comply with the following profiles + +# 9.6 +* When using this response type, the proposed orders (and any associated + resources) SHALL comply with the following profiles: + +# 9.7 +* When using this response type, the proposed orders (and any associated + resources) SHALL comply with the following profiles: + +# 9.8 +* This CRD capability SHALL NOT be used in situations where regulation dictates + the use of the X12 functionality. diff --git a/docs/crd-testing-notes.md b/docs/crd-testing-notes.md new file mode 100644 index 0000000..148d257 --- /dev/null +++ b/docs/crd-testing-notes.md @@ -0,0 +1,187 @@ +# Client Testing + +* Make discovery endpoint with services + * Require Auth for discovery request. This can be used to link discovery + request to a particular session. I'm not sure whether that is useful, + though. + * Include prefetch queries **RULE REQUIRED** + * Include configuration options **OPTIONAL** + * Hook Types + * appointment-book **RULE REQUIRED** + * encounter-start **RULE REQUIRED** + * encounter-discharge **RULE REQUIRED** + * order-dispatch **RULE REQUIRED** + * order-select **RULE REQUIRED** + * order-sign **RULE REQUIRED** +* Receive incoming hook request + * Verify jwt in Auth header + * Verify required fields + * hook + * hookInstance + * context + * Verify optional fields + * fhirServer + * fhirAuthorization + * prefetch + * Verify context + * appointment-book **RULE REQUIRED** + * userId **REQUIRED** + * patientId **REQUIRED** + * encounterId + * appointments **REQUIRED** + * encounter-start **RULE REQUIRED** + * userId **REQUIRED** + * patientId **REQUIRED** + * encounterId **REQUIRED** + * encounter-discharge **RULE REQUIRED** + * userId **REQUIRED** + * patientId **REQUIRED** + * encounterId **REQUIRED** + * order-dispatch **RULE REQUIRED** + * patientId **REQUIRED** + * dispatchedOrders **REQUIRED** + * performer **REQUIRED** + * fulfillmentTasks + * order-select **RULE REQUIRED** + * userId **REQUIRED** + * patientId **REQUIRED** + * encounterId + * selections **REQUIRED** + * draftOrders **REQUIRED** + * order-sign **RULE REQUIRED** + * userId **REQUIRED** + * patientId **REQUIRED** + * encounterId + * draftOrders **REQUIRED** + * Make FHIR requests + * Lifetime of token received via hook request is very limited, so it is not + suitable for extensive FHIR API testing. + * Fetch context resources + * Verify prefetch + * Return cards + * Card Types + * External link **Required** + * Instructions **Required** + * Coverage information systemAction **Required** + * REQUIREMENT OUTSIDE OF SCOPE - "If a CRD client submits a claim + related to an order for which it has received a coverage-information + extension for the coverage type associated with the claim, that claim + SHALL include the coverage-assertion-id and, if applicable, the + satisfied-pa-id in the X12 837 K3 segment." + * CRD clients and services SHALL support the new CDS Hooks system action + functionality to cause annotations to automatically be stored on the + relevant request, appointment, etc. without any user intervention. In + this case, the discrete information propagated into the order + extension SHALL be available to the user for viewing. + * Propose alternate request + * Multiple alternatives can be proposed by providing multiple + suggestions. + * Identify additional orders as companions/prerequisites for curret order + * Request form completion + * Instead of using a card, CRD services MAY opt to use a `systemAction` + instead. CRD clients supporting this card type SHALL support either + approach. + * Create or update coverage information + * Instead of using a card, CRD services MAY opt to use a systemAction + instead. CRD clients supporting this card type SHALL support either + approach. + * Launch SMART application **RULE REQUIRED** + * Use card to perform an EHR Launch of Inferno and perform comprehensive + FHIR API tests. + * MS: NOT TESTABLE - "if the client maintains the data element and surfaces it + to users, then it SHALL be exposed in their FHIR interface when the data + exists and privacy constraints permit" + * IS THIS TESTABLE? - "When a Coverage Information card type indicating that + additional clinical documentation is needed and the CRD client supports DTR, + CRD Clients SHALL ensure that clinical users have an opportunity to launch + the DTR app as part of the current workflow." + +# Server Testing +* Make discovery request + * Verify that services for required hook types are present + * Verify that services cover any other required capabilities +* Make hook requests + * Allow user to define a collection of requests which will cover all required + capabilities + * Hook Types + * appointment-book **RULE REQUIRED** + * encounter-start **RULE REQUIRED** + * encounter-discharge **RULE REQUIRED** + * order-dispatch **RULE REQUIRED** + * order-select **RULE REQUIRED** + * order-sign **RULE REQUIRED** + * Card Types + * External link **Required** + * SHALL have at least one `Card.link` + * `Link.type` SHALL have a type of `absolute` + * Instructions **Required** + * Coverage information systemAction **Required** + * If multiple extension repetitions are present, all repetitions + referencing differing insurance (coverage-information.coverage) SHALL + have distinct coverage-assertion-ids and satisfied-pa-ids (if present). + * Where multiple repetions apply to the same coverage, they SHALL have + the same coverage-assertion-ids and satisfied-pa-ids (if present). + * When using this response type, the proposed order or appointment being + updated SHALL comply with the following profiles: + * profile-appointment + * profile-devicerequest + * profile-medicationrequest + * profile-nutritionorder + * profile-servicerequest + * profile-visionprescription + * Propose alternate request + * When using this response type, the proposed orders (and any associated + resources) SHALL comply with the following profiles: + * profile-device + * profile-devicerequest + * profile-encounter† + * us-core-medication + * profile-medicationrequest + * profile-nutritionorder + * profile-servicerequest + * profile-visionprescription + * Identify additional orders as companions/prerequisites for current order + * When using this response type, the proposed orders (and any associated + resources) SHALL comply with the following profiles: + * profile-communicationrequest + * profile-device + * profile-devicerequest + * us-core-medication + * profile-medicationrequest + * profile-nutritionorder + * profile-servicerequest + * profile-visionprescription + * Request form completion + * This suggestion will always include a “create” action for the Task. + * The Task will point to the questionnaire to be completed using a + `Task.input` element with a `Task.input.type.text` of “questionnaire” + and the canonical URL for the questionnaire in + `Task.input.valueCanonical`. + * Additional `Task.input` elements will provide information about how the + completed questionnaire is to be submitted to the payer with a service + endpoint if required. + * The Task.code will always include the CRD-specific + `complete-questionnaire` code. + * The reason for completion will be conveyed in `Task.reasonCode`. + * Instead of using a card, CRD services MAY opt to use a systemAction + instead. + * When using this response type, the proposed orders (and any associated + resources) SHALL comply with the following profiles: + * profile-taskquestionnaire + * Create or update coverage information + * Instead of using a card, CRD services MAY opt to use a systemAction + instead. + * This response will contain a single suggestion. The primary action will + either be a suggestion to “update” an existing Coverage instance (if the + CRD Client already has one) or to “create” a new Coverage instance if + the CRD Server is aware of Coverage that the CRD Client is not. In + addition, the suggestion could include updates on all relevant Request + resources to add or remove links to Coverage instances, reflecting which + Coverages are relevant to which types of requests. + * Launch SMART application **RULE REQUIRED** + * Use card to perform an EHR Launch of Inferno and perform FHIR API + tests. + * the `Link.type` will be “smart” instead of “absolute”. The + Link.appContext will typically also be present. + * MS: NOT TESTABLE - "the server SHALL leverage mustSupport elements as + available and appropriate to provide decision support" diff --git a/lib/davinci_crd_test_kit.rb b/lib/davinci_crd_test_kit.rb new file mode 100644 index 0000000..505a594 --- /dev/null +++ b/lib/davinci_crd_test_kit.rb @@ -0,0 +1,2 @@ +require_relative 'davinci_crd_test_kit/crd_server_suite' +require_relative 'davinci_crd_test_kit/crd_client_suite' diff --git a/lib/davinci_crd_test_kit/card_responses/companions_prerequisites.json b/lib/davinci_crd_test_kit/card_responses/companions_prerequisites.json new file mode 100644 index 0000000..afc7139 --- /dev/null +++ b/lib/davinci_crd_test_kit/card_responses/companions_prerequisites.json @@ -0,0 +1,58 @@ +{ + "summary": "Additional Orders As Companions/Prerequisites Card", + "detail": "This is a Companions/Prerequisites Card that recommends the introduction of additional orders", + "indicator": "info", + "source": { + "label": "Inferno", + "url": "https://inferno.healthit.gov/", + "topic": { + "system": "http://hl7.org/fhir/us/davinci-crd/CodeSystem/temp", + "code": "coverage-info", + "display": "Coverage Information" + } + }, + "selectionBehavior": "any", + "suggestions": [ + { + "label": "Add monthly physical assessment for the first 3 months", + "actions": [ + { + "type": "create", + "description": "Add order for physical assessment", + "resource": { + "resourceType": "ServiceRequest", + "status": "draft", + "intent": "order", + "category": [ + { + "coding": [ + { + "system": "http://snomed.info/sct", + "code": "409063005", + "display": "Counselling" + } + ] + } + ], + "code": { + "coding": [ + { + "system": "http://snomed.info/sct", + "code": "225885004", + "display": "Health assessment (procedure)" + } + ] + }, + "subject": { + "reference": "http://example.org/fhir/Patient/example" + }, + "authoredOn": "2019-02-15", + "requester": { + "reference": "http://example.org/fhir/PractitionerRole/example" + } + } + } + ] + } + ] +} diff --git a/lib/davinci_crd_test_kit/card_responses/create_update_coverage_information.json b/lib/davinci_crd_test_kit/card_responses/create_update_coverage_information.json new file mode 100644 index 0000000..e6bb7f6 --- /dev/null +++ b/lib/davinci_crd_test_kit/card_responses/create_update_coverage_information.json @@ -0,0 +1,20 @@ +{ + "summary": "Create/Update Coverage Information Card", + "detail": "This is a Create/Update Coverage Information Card which is sent when the CRD server is aware of additional coverage that is relevant to the current/proposed activity or has updates/corrections to make to the information held by the CRD Client", + "indicator": "info", + "source": { + "label": "Inferno", + "url": "https://inferno.healthit.gov/", + "topic": { + "system": "http://hl7.org/fhir/us/davinci-crd/CodeSystem/temp", + "code": "coverage-info", + "display": "Coverage Information" + } + }, + "selectionBehavior": "any", + "suggestions": [ + { + "label": "Create/Update coverage information" + } + ] +} diff --git a/lib/davinci_crd_test_kit/card_responses/external_reference.json b/lib/davinci_crd_test_kit/card_responses/external_reference.json new file mode 100644 index 0000000..1190bdc --- /dev/null +++ b/lib/davinci_crd_test_kit/card_responses/external_reference.json @@ -0,0 +1,21 @@ +{ + "summary": "External Reference Card", + "detail": "This is an External Reference Card containing one or more links to external web pages, PDFs, or other resources that provide relevant coverage information.", + "indicator": "info", + "source": { + "label": "Inferno", + "url": "https://inferno.healthit.gov/", + "topic": { + "system": "http://hl7.org/fhir/us/davinci-crd/CodeSystem/temp", + "code": "guideline", + "display": "Guideline" + } + }, + "links": [ + { + "label": "CRD IG External Reference Card Info", + "url": "https://build.fhir.org/ig/HL7/davinci-crd/cards.html#external-reference", + "type": "absolute" + } + ] +} \ No newline at end of file diff --git a/lib/davinci_crd_test_kit/card_responses/instructions.json b/lib/davinci_crd_test_kit/card_responses/instructions.json new file mode 100644 index 0000000..83ca7ad --- /dev/null +++ b/lib/davinci_crd_test_kit/card_responses/instructions.json @@ -0,0 +1,14 @@ +{ + "summary": "Instructions Card", + "detail": "This is an Instructions card containing textual guidance to display to the user making the decisions.", + "indicator": "info", + "source": { + "label": "Inferno", + "url": "https://inferno.healthit.gov/", + "topic": { + "system": "http://hl7.org/fhir/us/davinci-crd/CodeSystem/temp", + "code": "coverage-info", + "display": "Coverage Information" + } + } +} \ No newline at end of file diff --git a/lib/davinci_crd_test_kit/card_responses/launch_smart_app.json b/lib/davinci_crd_test_kit/card_responses/launch_smart_app.json new file mode 100644 index 0000000..2241af3 --- /dev/null +++ b/lib/davinci_crd_test_kit/card_responses/launch_smart_app.json @@ -0,0 +1,21 @@ +{ + "summary": "Launch SMART Application Card", + "detail": "This is a Launch SMART Application Card which can cause the launching of SMART apps to occur in the context in which they are relevant to patient care and/or to payment-related decision-making.", + "indicator": "info", + "source": { + "label": "Inferno", + "url": "https://inferno.healthit.gov/", + "topic": { + "system": "http://hl7.org/fhir/us/davinci-crd/CodeSystem/temp", + "code": "coverage-info", + "display": "Coverage Information" + } + }, + "links": [ + { + "label": "Inferno", + "url": "INFERNO_PLACEHOLDER", + "type": "smart" + } + ] +} \ No newline at end of file diff --git a/lib/davinci_crd_test_kit/card_responses/propose_alternate_request.json b/lib/davinci_crd_test_kit/card_responses/propose_alternate_request.json new file mode 100644 index 0000000..b10a446 --- /dev/null +++ b/lib/davinci_crd_test_kit/card_responses/propose_alternate_request.json @@ -0,0 +1,71 @@ +{ + "summary": "Propose Alternate Request Card", + "detail": "This is a Propose Alternate Request Card which contains suggested alternatives to the current proposed therapy", + "indicator": "info", + "source": { + "label": "Inferno", + "url": "https://inferno.healthit.gov/", + "topic": { + "system": "http://hl7.org/fhir/us/davinci-crd/CodeSystem/temp", + "code": "coverage-info", + "display": "Coverage Information" + } + }, + "selectionBehavior": "any", + "suggestions": [ + { + "label": "Replace order with a health assessment first", + "actions": [ + { + "type": "create", + "description": "Order for patient health assessment", + "resource": { + "resourceType": "ServiceRequest", + "status": "draft", + "intent": "order", + "category": [ + { + "coding": [ + { + "system": "http://snomed.info/sct", + "code": "409063005", + "display": "Counselling" + } + ] + } + ], + "code": { + "coding": [ + { + "system": "http://snomed.info/sct", + "code": "225885004", + "display": "Health assessment (procedure)" + } + ] + }, + "subject": { + "reference": "http://example.org/fhir/Patient/123" + }, + "occurrenceTiming": { + "repeat": { + "boundsDuration": { + "value": 3, + "unit": "months", + "system": "http://unitsofmeasure.org", + "code": "mo" + }, + "frequency": 1, + "period": 1, + "periodUnit": "mo" + } + }, + "authoredOn": "2019-02-15", + "requester": { + "reference": "http://example.org/fhir/PractitionerRole/987" + } + } + } + ] + } + ] +} diff --git a/lib/davinci_crd_test_kit/card_responses/request_form_completion.json b/lib/davinci_crd_test_kit/card_responses/request_form_completion.json new file mode 100644 index 0000000..7077dbe --- /dev/null +++ b/lib/davinci_crd_test_kit/card_responses/request_form_completion.json @@ -0,0 +1,227 @@ +{ + "summary": "Request Form Completion Card", + "detail": "This is a Request Form Completion Card which indicates that there are forms that need to be completed.", + "indicator": "info", + "source": { + "label": "Inferno", + "url": "https://inferno.healthit.gov/", + "topic": { + "system": "http://hl7.org/fhir/us/davinci-crd/CodeSystem/temp", + "code": "coverage-info", + "display": "Coverage Information" + } + }, + "selectionBehavior": "any", + "suggestions": [ + { + "label" : "Add 'completion of the ABC form' to your task list (possibly for reassignment)", + "actions" : [ + { + "type" : "create", + "description" : "Add version 2 of the XYZ form to the clinical system's repository (if it doesn't already exist)", + "resource" : { + "resourceType": "Questionnaire", + "id": "XYZ", + "url": "http://example.org/Questionnaire/XYZ", + "title": "Cancer Quality Forum Questionnaire XYZ", + "version": 2, + "status": "active", + "subjectType": [ + "Patient" + ], + "date": "2012-01", + "item": [ + { + "linkId": "1", + "code": [ + { + "system": "http://example.org/system/code/sections", + "code": "COMORBIDITY" + } + ], + "type": "group", + "item": [ + { + "linkId": "1.1", + "code": [ + { + "system": "http://example.org/system/code/questions", + "code": "COMORB" + } + ], + "prefix": "1", + "type": "choice", + "answerValueSet": "http://hl7.org/fhir/ValueSet/yesnodontknow", + "item": [ + { + "linkId": "1.1.1", + "code": [ + { + "system": "http://example.org/system/code/sections", + "code": "CARDIAL" + } + ], + "type": "group", + "enableWhen": [ + { + "question": "1.1", + "operator": "=", + "answerCoding": { + "system": "http://terminology.hl7.org/CodeSystem/v2-0136", + "code": "Y" + } + } + ], + "item": [ + { + "linkId": "1.1.1.1", + "code": [ + { + "system": "http://example.org/system/code/questions", + "code": "COMORBCAR" + } + ], + "prefix": "1.1", + "type": "choice", + "answerValueSet": "http://hl7.org/fhir/ValueSet/yesnodontknow", + "item": [ + { + "linkId": "1.1.1.1.1", + "code": [ + { + "system": "http://example.org/system/code/questions", + "code": "COMCAR00", + "display": "Angina Pectoris" + }, + { + "system": "http://snomed.info/sct", + "code": "194828000", + "display": "Angina (disorder)" + } + ], + "prefix": "1.1.1", + "type": "choice", + "answerValueSet": "http://hl7.org/fhir/ValueSet/yesnodontknow" + }, + { + "linkId": "1.1.1.1.2", + "code": [ + { + "system": "http://snomed.info/sct", + "code": "22298006", + "display": "Myocardial infarction (disorder)" + } + ], + "prefix": "1.1.2", + "type": "choice", + "answerValueSet": "http://hl7.org/fhir/ValueSet/yesnodontknow" + } + ] + }, + { + "linkId": "1.1.1.2", + "code": [ + { + "system": "http://example.org/system/code/questions", + "code": "COMORBVAS" + } + ], + "prefix": "1.2", + "type": "choice", + "answerValueSet": "http://hl7.org/fhir/ValueSet/yesnodontknow" + } + ] + } + ] + } + ] + }, + { + "linkId": "2", + "code": [ + { + "system": "http://example.org/system/code/sections", + "code": "HISTOPATHOLOGY" + } + ], + "type": "group", + "item": [ + { + "linkId": "2.1", + "code": [ + { + "system": "http://example.org/system/code/sections", + "code": "ABDOMINAL" + } + ], + "type": "group", + "item": [ + { + "linkId": "2.1.2", + "code": [ + { + "system": "http://example.org/system/code/questions", + "code": "STADPT", + "display": "pT category" + } + ], + "type": "choice" + } + ] + } + ] + } + ] + }, + "extension": { + "davinci-crd.if-none-exist": "url=http://example.org/Questionnaire/XYZ&version=2" + } + }, + { + "type" : "create", + "description" : "Add 'Complete ABC form' to the task list", + "resource" : { + "resourceType" : "Task", + "status" : "ready", + "intent" : "order", + "code" : { + "coding" : [ + { + "system" : "http://hl7.org/fhir/uv/sdc/CodeSystem/temp", + "code" : "complete-questionnaire" + } + ] + }, + "description" : "Complete XYZ form for local retention", + "for" : { + "reference" : "http://example.org/fhir/Patient/123" + }, + "authoredOn" : "2018-08-09", + "input" : [ + { + "type" : { + "text" : "questionnaire" + }, + "valueCanonical" : "http://example.org/Questionnaire/XYZ|2" + }, + { + "type" : { + "text" : "afterCompletion" + }, + "valueCodeableConcept" : { + "coding" : [ + { + "system" : "http://example.org/fhir/CodeSystem/SomeCodes", + "code" : "987", + "display" : "Local Use" + } + ] + } + } + ] + } + } + ] + } + ] +} diff --git a/lib/davinci_crd_test_kit/cards_validation.rb b/lib/davinci_crd_test_kit/cards_validation.rb new file mode 100644 index 0000000..ec541b6 --- /dev/null +++ b/lib/davinci_crd_test_kit/cards_validation.rb @@ -0,0 +1,234 @@ +require_relative 'server_hook_request_validation' +require_relative 'suggestion_actions_validation' + +module DaVinciCRDTestKit + module CardsValidation + include DaVinciCRDTestKit::ServerHookRequestValidation + include DaVinciCRDTestKit::SuggestionActionsValidation + + HOOKS = [ + 'appointment-book', 'encounter-discharge', 'encounter-start', + 'order-dispatch', 'order-select', 'order-sign' + ].freeze + + def card_required_fields + { 'summary' => String, 'indicator' => String, 'source' => Hash } + end + + def source_required_fields + { 'label' => String, 'topic' => Hash } + end + + def source_topic_required_fields + { 'code' => String, 'system' => String } + end + + def card_optional_fields + { + 'uuid' => String, + 'detail' => String, + 'suggestions' => Array, + 'overrideReasons' => Array, + 'links' => Array + } + end + + def override_reasons_required_fields + { 'code' => String, 'system' => String, 'display' => String } + end + + def link_required_fields + { 'label' => String, 'type' => String, 'url' => 'URL' } + end + + def valid_card_with_optionals?(card) + current_error_count = messages.count { |msg| msg[:type] == 'error' } + card_optional_fields.each do |field, type| + next unless card[field] + + validate_presence_and_type(card, field, type, 'Card') + end + + card_selection_behavior_check(card) + card_override_reasons_check(card) + card_links_check(card) + card_suggestions_check(card) + + current_error_count == messages.count { |msg| msg[:type] == 'error' } + end + + def card_selection_behavior_check(card) + return unless card['suggestions'].present? + + selection_behavior = card['selectionBehavior'] + unless selection_behavior + add_message('error', "`Card.selectionBehavior` must be provided if suggestions are present. In Card `#{card}`") + return + end + + allowed_values = ['at-most-one', 'any'] + return if allowed_values.include?(selection_behavior) + + error_msg = "`selectionBehavior` #{selection_behavior} not allowed. " \ + "Allowed values: #{allowed_values.to_sentence}. In Card `#{card}`" + add_message('error', error_msg) + end + + def card_override_reasons_check(card) + return unless card['overrideReasons'].is_a?(Array) + + card['overrideReasons'].each do |reason| + override_reasons_required_fields.each do |field, type| + validate_presence_and_type(reason, field, type, 'OverrideReason Coding') + end + end + end + + def card_links_check(card) + return unless card['links'].is_a?(Array) && card['links'].present? + + card['links'].each do |link| + link_required_fields.each do |field, type| + validate_presence_and_type(link, field, type, 'Link') + end + + card_link_type_check(card, link) + end + end + + def card_link_type_check(card, link) + return unless link['type'] + + unless ['absolute', 'smart'].include?(link['type']) + add_message('error', + "`Link.type` must be `absolute` or `smart`. Got `#{link['type']}`: `#{link}`. In Card `#{card}`") + return + end + + return unless link['type'] == 'absolute' && link['appContext'].present? + + msg = '`appContext` field should only be valued if the link type is smart and is not valid for absolute links: ' \ + "`#{link}`. In Card `#{card}`" + add_message('error', msg) + end + + def card_suggestions_check(card) + return unless card['suggestions'].is_a?(Array) && card['suggestions'].present? + + card['suggestions'].each do |suggestion| + process_suggestion(card, suggestion) + end + end + + def process_suggestion(card, suggestion) + validate_presence_and_type(suggestion, 'label', String, 'Suggestion') + return unless suggestion['actions'] + + validate_and_process_actions(card, suggestion) + end + + def validate_and_process_actions(card, suggestion) + actions = suggestion['actions'] + if !actions.is_a?(Array) + add_message('error', "Suggestion `actions` field is not of type Array: `#{suggestion}`. In Card `#{card}`") + return + elsif actions.empty? + add_message('error', + "Suggestion `actions` field should not be an empty Array: `#{suggestion}`. In Card `#{card}`") + return + end + + actions.each do |action| + action_fields_validation(action) + end + end + + def card_source_check(card) + source = card['source'] + return unless source.is_a?(Hash) + + source_required_fields.each do |field, type| + validate_presence_and_type(source, field, type, 'Source') + end + + card_source_topic_check(source['topic']) + # TODO: How to validate topic binding to the ValueSet CRD Card Types? + end + + def card_source_topic_check(topic) + return unless topic.is_a?(Hash) + + source_topic_required_fields.each do |field, type| + validate_presence_and_type(topic, field, type, 'Source topic') + end + end + + def card_summary_check(card) + return if !card['summary'].is_a?(String) || card['summary'].length < 140 + + add_message('error', "`summary` is over the 140-character limit: `#{card}`") + end + + def card_indicator_check(card) + return if !card['indicator'].is_a?(String) || ['info', 'warning', 'critical'].include?(card['indicator']) + + msg = "`indicator` is `#{card['indicator']}`. Allowed values are `info`, `warning`, `critical`: `#{card}`" + add_message('error', msg) + end + + def cards_check(cards) + cards.each do |card| + current_error_count = messages.count { |msg| msg[:type] == 'error' } + card_required_fields.each do |field, type| + validate_presence_and_type(card, field, type, 'Card') + end + + card_summary_check(card) + card_indicator_check(card) + card_source_check(card) + + valid_cards << card if current_error_count == messages.count { |msg| msg[:type] == 'error' } + end + end + + def perform_cards_validation(cards, response_index = 0) + unless cards + add_message('error', "Server response #{response_index + 1} did not have the `cards` field.") + return + end + unless cards.is_a?(Array) + add_message('error', "`cards` field of server response #{response_index + 1} is not an array.") + return + end + warning do + assert cards.present?, "Server response #{response_index + 1} has no decision support." + end + cards_check(cards) + end + + def all_requests + @all_requests ||= HOOKS.each_with_object([]) do |hook, reqs| + load_tagged_requests(hook) + reqs.concat(requests) + end + end + + def extract_all_valid_cards_from_hooks_responses + all_requests.keep_if { |request| request.status == 200 } + all_requests.each_with_index do |request, index| + service_response = JSON.parse(request.response_body) + perform_cards_validation(service_response['cards'], index) + rescue JSON::ParserError + add_message('error', "Invalid JSON: server response #{index + 1} is not a valid JSON.") + end + end + + def extract_valid_cards_with_links_from_hooks_responses + extract_all_valid_cards_from_hooks_responses + + valid_cards.each do |card| + valid_cards_with_links << card if valid_card_with_optionals?(card) && (card['links']) + end + end + end +end diff --git a/lib/davinci_crd_test_kit/client_fhir_api_group.rb b/lib/davinci_crd_test_kit/client_fhir_api_group.rb new file mode 100644 index 0000000..06d054e --- /dev/null +++ b/lib/davinci_crd_test_kit/client_fhir_api_group.rb @@ -0,0 +1,762 @@ +require 'tls_test_kit' +require_relative 'crd_options' +require_relative 'client_tests/client_fhir_api_read_test' +require_relative 'client_tests/client_fhir_api_search_test' +require_relative 'client_tests/client_fhir_api_create_test' +require_relative 'client_tests/client_fhir_api_update_test' +require_relative 'client_tests/client_fhir_api_validation_test' +require 'smart_app_launch/smart_stu1_suite' +require 'smart_app_launch/smart_stu2_suite' + +module DaVinciCRDTestKit + class ClientFHIRAPIGroup < Inferno::TestGroup + title 'FHIR API' + description <<~DESCRIPTION + Systems wishing to conform to the [CRD Client](https://hl7.org/fhir/us/davinci-crd/STU2/CapabilityStatement-crd-client.html) + role are responsible for returning data requested by the CRD Server needed to provide decision support. The Da + Vinci CRD Client FHIR API Test Group contains tests that test the ['server' capabilities](https://hl7.org/fhir/us/davinci-crd/CapabilityStatement-crd-client.html#resourcesSummary1) + of the CRD Client and ensures that the CRD Client can respond to CRD Server queriers. These 'server' capabilities + are based on [US Core](https://hl7.org/fhir/us/core/STU3.1.1/). This test kit does not test the base US Core + capabilities. In addition to the U.S. Core expectations, the CRD Client SHALL support all 'SHOULD' `read` and + `search` capabilities listed for resources referenced in supported hooks and order types if it does not support + returning the associated resources as part of CDS Hooks pre-fetch. The CRD Client SHALL also support `update` + functionality for all resources listed where the client allows invoking hooks based on the resource. + + This test group contains two main groups of tests: + * SMART App Launch Authorization: A group of tests that perform FHIR API authorization using [SMART on FHIR](https://hl7.org/fhir/smart-app-launch/index.html) + EHR Launch Sequence + * CRD FHIR RESTful Capabilities: A group of tests that test each CRD resource profile and ensure the CRD client + supports the appropriate FHIR operations required on each resource + DESCRIPTION + id :crd_client_fhir_api + + input :url, + title: 'FHIR Endpoint', + description: 'URL of the CRD FHIR server' + + group do + title 'Authorization' + description %( + Perform an EHR [SMART App Launch](https://www.hl7.org/fhir/smart-app-launch/) to Authorize the client FHIR + server with Inferno so that Inferno may access resources on the FHIR server in order to perform the FHIR RESTful + Capabilities tests. + ) + + group from: :smart_discovery do + required_suite_options CRDOptions::SMART_1_REQUIREMENT + run_as_group + + test from: :tls_version_test do + title 'CRD FHIR Server is secured by transport layer security' + description <<~DESCRIPTION + Under [Privacy, Security, and Safety](https://hl7.org/fhir/us/davinci-crd/STU2/security.html), + the CRD Implementation Guide imposes the following rule about TLS: + + As per the [CDS Hook specification](https://cds-hooks.hl7.org/2.0/#security-and-safety), + communications between CRD Clients and CRD Servers SHALL + use TLS. Mutual TLS is not required by this specification but is permitted. CRD Servers and + CRD Clients SHOULD enforce a minimum version and other TLS configuration requirements based + on HRex rules for PHI exchange. + + This test verifies that the FHIR server is using TLS 1.2 or higher. + DESCRIPTION + id :crd_server_tls_version_stu1 + + config( + options: { minimum_allowed_version: OpenSSL::SSL::TLS1_2_VERSION } + ) + end + end + + group from: :smart_ehr_launch, + required_suite_options: CRDOptions::SMART_1_REQUIREMENT, + run_as_group: true + + group from: :smart_discovery_stu2 do + required_suite_options CRDOptions::SMART_2_REQUIREMENT + run_as_group + + test from: :tls_version_test do + title 'CRD FHIR Server is secured by transport layer security' + description <<~DESCRIPTION + Under [Privacy, Security, and Safety](https://hl7.org/fhir/us/davinci-crd/STU2/security.html), + the CRD Implementation Guide imposes the following rule about TLS: + + As per the [CDS Hook specification](https://cds-hooks.hl7.org/2.0/#security-and-safety), + communications between CRD Clients and CRD Servers SHALL + use TLS. Mutual TLS is not required by this specification but is permitted. CRD Servers and + CRD Clients SHOULD enforce a minimum version and other TLS configuration requirements based + on HRex rules for PHI exchange. + + This test verifies that the FHIR server is using TLS 1.2 or higher. + DESCRIPTION + id :crd_server_tls_version_stu2 + + config( + options: { minimum_allowed_version: OpenSSL::SSL::TLS1_2_VERSION } + ) + end + end + + group from: :smart_ehr_launch_stu2, + required_suite_options: CRDOptions::SMART_2_REQUIREMENT, + run_as_group: true + + group from: :smart_openid_connect do + run_as_group + optional + config( + inputs: { + id_token: { name: :ehr_id_token }, + client_id: { name: :ehr_client_id }, + requested_scopes: { name: :ehr_requested_scopes }, + access_token: { name: :ehr_access_token }, + smart_credentials: { name: :ehr_smart_credentials } + } + ) + end + + group from: :smart_token_refresh do + run_as_group + optional + config( + inputs: { + refresh_token: { name: :ehr_refresh_token }, + client_id: { name: :ehr_client_id }, + client_secret: { name: :ehr_client_secret }, + received_scopes: { name: :ehr_received_scopes } + } + ) + end + end + + group do + title 'FHIR RESTful Capabilities' + description %( + This test group contains groups of tests for each CRD resource profile and ensures the [CRD Client](https://hl7.org/fhir/us/davinci-crd/STU2/CapabilityStatement-crd-client.html) + supports the appropriate FHIR operations required on each resource. For each resource, Inferno will perform the + required FHIR operations, and then it will validate any resources that are returned as a result of + these FHIR operations. + + The resources that are a part of the CRD IG configuration include: + * [Appointment](https://hl7.org/fhir/us/davinci-crd/STU2/CapabilityStatement-crd-client.html#Appointment1-1) + * [CommunicationRequest](https://hl7.org/fhir/us/davinci-crd/STU2/CapabilityStatement-crd-client.html#CommunicationRequest1-2) + * [Coverage](https://hl7.org/fhir/us/davinci-crd/STU2/CapabilityStatement-crd-client.html#Coverage1-3) + * [Device](https://hl7.org/fhir/us/davinci-crd/STU2/CapabilityStatement-crd-client.html#Device1-4) + * [DeviceRequest](https://hl7.org/fhir/us/davinci-crd/STU2/CapabilityStatement-crd-client.html#DeviceRequest1-5) + * [Encounter](https://hl7.org/fhir/us/davinci-crd/STU2/CapabilityStatement-crd-client.html#Encounter1-6) + * [Patient](https://hl7.org/fhir/us/davinci-crd/STU2/CapabilityStatement-crd-client.html#Patient1-7) + * [Practitioner](https://hl7.org/fhir/us/davinci-crd/STU2/CapabilityStatement-crd-client.html#Practitioner1-8) + * [PractitionerRole](https://hl7.org/fhir/us/davinci-crd/STU2/CapabilityStatement-crd-client.html#PractitionerRole1-9) + * [Location](https://hl7.org/fhir/us/davinci-crd/STU2/CapabilityStatement-crd-client.html#Location1-10) + * [MedicationRequest](https://hl7.org/fhir/us/davinci-crd/STU2/CapabilityStatement-crd-client.html#MedicationRequest1-11) + * [NutritionOrder](https://hl7.org/fhir/us/davinci-crd/STU2/CapabilityStatement-crd-client.html#NutritionOrder1-12) + * [Organization](https://hl7.org/fhir/us/davinci-crd/STU2/CapabilityStatement-crd-client.html#Organization1-13) + * [ServiceRequest](https://hl7.org/fhir/us/davinci-crd/STU2/CapabilityStatement-crd-client.html#ServiceRequest1-14) + * [ClaimResponse](https://hl7.org/fhir/us/davinci-crd/STU2/CapabilityStatement-crd-client.html#ClaimResponse1-15) + * [Task](https://hl7.org/fhir/us/davinci-crd/STU2/CapabilityStatement-crd-client.html#Task1-16) + * [VisionPrescription](https://hl7.org/fhir/us/davinci-crd/STU2/CapabilityStatement-crd-client.html#VisionPrescription1-17) + ) + input :url + input :ehr_smart_credentials, + type: :oauth_credentials, + title: 'OAuth Credentials', + optional: true + + fhir_client do + url :url + oauth_credentials :ehr_smart_credentials + end + + group do + title 'Appointment' + description %( + Verify the CRD client can perform the required FHIR interactions on the Appointment resource, and + validate any returned resources against the [CRD Appointment profile](https://hl7.org/fhir/us/davinci-crd/STU2/StructureDefinition-profile-appointment.html) + + Required Appointment resource FHIR interactions: + * SHOULD support `update` + + Resource Conformance: SHOULD + ) + optional + + test from: :crd_client_fhir_api_update_test, + optional: true, + config: { + options: { resource_type: 'Appointment' }, + inputs: { + update_resources: { + name: :appointment_update_resources, + title: 'Appointment Resources' + } + } + } + end + + group do + title 'CommunicationRequest' + description %( + Verify the CRD client can perform the required FHIR interactions on the CommunicationRequest resource, and + validate any returned resources against the [CRD CommunicationRequest profile](https://hl7.org/fhir/us/davinci-crd/STU2/StructureDefinition-profile-communicationrequest.html) + + Required CommunicationRequest resource FHIR interactions: + * SHOULD support `update` + + Resource Conformance: SHOULD + ) + optional + + test from: :crd_client_fhir_api_update_test, + optional: true, + config: { + options: { resource_type: 'CommunicationRequest' }, + inputs: { + update_resources: { + name: :communication_request_update_resources, + title: 'CommunicationRequest Resources' + } + } + } + end + + group do + title 'Coverage' + description %( + Verify the CRD client can perform the required FHIR interactions on the Coverage resource, and + validate any returned resources against the [CRD Coverage profile](https://hl7.org/fhir/us/davinci-crd/STU2/StructureDefinition-profile-coverage.html) + + Required Coverage resource FHIR interactions: + * SHALL suport search by [`patient`](http://hl7.org/fhir/R4/coverage.html#search) + * SHALL suport search by [`status`](http://hl7.org/fhir/R4/coverage.html#search) + + Resource Conformance: SHALL + ) + + test from: :crd_client_fhir_api_search_test, + id: :crd_client_coverage_patient_search_test, + title: 'Search by patient', + config: { + options: { resource_type: 'Coverage', search_type: 'patient' }, + inputs: { search_param_values: { + name: :patient_ids, + title: 'Patient IDs', + description: 'Comma separated list of Patient IDs that in sum contain all MUST SUPPORT elements' + } } + } + + test from: :crd_client_fhir_api_search_test, + id: :crd_client_coverage_status_search_test, + title: 'Search by status', + config: { + options: { resource_type: 'Coverage', search_type: 'status' }, + inputs: { search_param_values: { + name: :patient_ids + } } + } + + test from: :crd_client_fhir_api_validation_test, + config: { + options: { resource_type: 'Coverage' } + } + end + + group do + title 'Device' + description %( + Verify the CRD client can perform the required FHIR interactions on the Device resource, and + validate any returned resources against the [CRD Device profile](https://hl7.org/fhir/us/davinci-crd/STU2/StructureDefinition-profile-device.html) + + Required Device resource FHIR interactions: + * SHOULD support `read` + + Resource Conformance: SHOULD + ) + optional + + test from: :crd_client_fhir_api_read_test, + optional: true, + config: { + options: { resource_type: 'Device' }, + inputs: { + resource_ids: { + name: :device_ids, + title: 'Device IDs', + description: 'Comma separated list of Device IDs that in sum contain all MUST SUPPORT elements' + } + } + } + + test from: :crd_client_fhir_api_validation_test, + config: { + options: { resource_type: 'Device' } + } + end + + group do + title 'DeviceRequest' + description %( + Verify the CRD client can perform the required FHIR interactions on the DeviceRequest resource, and + validate any returned resources against the [CRD DeviceRequest profile](https://hl7.org/fhir/us/davinci-crd/STU2/StructureDefinition-profile-devicerequest.html) + + Required DeviceRequest resource FHIR interactions: + * SHOULD support `update` + + Resource Conformance: SHOULD + ) + optional + + test from: :crd_client_fhir_api_update_test, + optional: true, + config: { + options: { resource_type: 'DeviceRequest' }, + inputs: { + update_resources: { + name: :device_request_update_resources, + title: 'DeviceRequest Resources' + } + } + } + end + + group do + title 'Encounter' + description %( + Verify the CRD client can perform the required FHIR interactions on the Encounter resource, and + validate any returned resources against the [CRD Encounter profile](https://hl7.org/fhir/us/davinci-crd/STU2/StructureDefinition-profile-encounter.html) + + Required Encounter resource FHIR interactions: + * SHOULD support `update` + * SHALL support search by [`_id`](http://hl7.org/fhir/R4/encounter.html#search) + * SHALL support search by [`organization`](http://hl7.org/fhir/R4/encounter.html#search) and + performing an `_include` on Location + + Resource Conformance: SHALL + ) + + test from: :crd_client_fhir_api_update_test, + optional: true, + config: { + options: { resource_type: 'Encounter' }, + inputs: { + update_resources: { + name: :encounter_update_resources, + title: 'Encounter Resources' + } + } + } + + test from: :crd_client_fhir_api_search_test, + id: :crd_client_encounter_id_search_test, + title: 'Search by _id', + config: { + options: { resource_type: 'Encounter', search_type: '_id' }, + inputs: { search_param_values: { + name: :encounter_ids, + title: 'Encounter IDs', + description: 'Comma separated list of Encounter IDs that in sum contain all MUST SUPPORT elements' + } } + } + + test from: :crd_client_fhir_api_search_test, + id: :crd_client_encounter_organization_search_test, + title: 'Search by organization', + config: { + options: { resource_type: 'Encounter', search_type: 'organization' }, + inputs: { search_param_values: { + name: :organization_ids, + title: 'Organization IDs', + description: 'Comma separated list of Organization IDs that in sum contain all MUST SUPPORT elements' + } } + } + + test from: :crd_client_fhir_api_search_test, + id: :crd_client_encounter_location_include_test, + title: 'Search by _id and _include location', + config: { + options: { resource_type: 'Encounter', search_type: 'location_include' }, + inputs: { search_param_values: { + name: :encounter_ids, + title: 'Encounter IDs', + description: 'Comma separated list of Encounter IDs that in sum contain all MUST SUPPORT elements' + } } + } + + test from: :crd_client_fhir_api_validation_test, + config: { + options: { resource_type: 'Encounter' } + } + end + + group do + title 'Patient' + description %( + Verify the CRD client can perform the required FHIR interactions on the Patient resource, and + validate any returned resources against the [CRD Patient profile](https://hl7.org/fhir/us/davinci-crd/STU2/StructureDefinition-profile-patient.html) + + Required Patient resource FHIR interactions: + * SHOULD support `read` + + Resource Conformance: SHOULD + ) + optional + + test from: :crd_client_fhir_api_read_test, + optional: true, + config: { + options: { resource_type: 'Patient' }, + inputs: { + resource_ids: { + name: :patient_ids, + title: 'Patient IDs', + description: 'Comma separated list of Patient IDs that in sum contain all MUST SUPPORT elements' + } + } + } + + test from: :crd_client_fhir_api_validation_test, + config: { + options: { resource_type: 'Patient' } + } + end + + group do + title 'Practitioner' + description %( + Verify the CRD client can perform the required FHIR interactions on the Practitioner resource, and + validate any returned resources against the [CRD Practitioner profile](https://hl7.org/fhir/us/davinci-crd/STU2/StructureDefinition-profile-practitioner.html) + + Required Practitioner resource FHIR interactions: + * SHOULD support `read` + + Resource Conformance: SHOULD + ) + optional + + test from: :crd_client_fhir_api_read_test, + optional: true, + config: { + options: { resource_type: 'Practitioner' }, + inputs: { + resource_ids: { + name: :practitioner_ids, + title: 'Practitioner IDs', + description: 'Comma separated list of Practitioner IDs that in sum contain all MUST SUPPORT elements' + } + } + } + + test from: :crd_client_fhir_api_validation_test, + config: { + options: { resource_type: 'Practitioner' } + } + end + + group do + title 'PractitionerRole' + description %( + Verify the CRD client can perform the required FHIR interactions on the PractitionerRole resource, and + validate any returned resources against the [US Core PractitionerRole profile](https://hl7.org/fhir/us/core/STU3.1.1/StructureDefinition-us-core-practitionerrole.html) + + Required PractitionerRole resource FHIR interactions: + * SHALL support search by [`_id`](http://hl7.org/fhir/R4/practitionerrole.html#search) + * SHALL support search by [`organization`](http://hl7.org/fhir/R4/practitionerrole.html#search) and + performing an `_include` on Organization + * SHALL support search by [`practitioner`](http://hl7.org/fhir/R4/practitionerrole.html#search) and + performing an `_include` on Practitioner + + Resource Conformance: SHALL + ) + + test from: :crd_client_fhir_api_search_test, + id: :crd_client_practitioner_role_id_search_test, + title: 'Search by _id', + config: { + options: { resource_type: 'PractitionerRole', search_type: '_id' }, + inputs: { search_param_values: { + name: :practitioner_role_ids, + title: 'PractitionerRole IDs', + description: 'Comma separated list of Practitioner IDs that in sum contain all MUST SUPPORT elements' + } } + } + + test from: :crd_client_fhir_api_search_test, + id: :crd_client_practitioner_role_organization_search_test, + title: 'Search by organization', + config: { + options: { resource_type: 'PractitionerRole', search_type: 'organization' }, + inputs: { search_param_values: { + name: :organization_ids, + title: 'Organization IDs', + description: 'Comma separated list of Organization IDs that in sum contain all MUST SUPPORT elements' + } } + } + + test from: :crd_client_fhir_api_search_test, + id: :crd_client_practitioner_role_practitioner_search_test, + title: 'Search by practitioner', + config: { + options: { resource_type: 'PractitionerRole', search_type: 'practitioner' }, + inputs: { search_param_values: { + name: :practitioner_ids, + title: 'Practitioner IDs', + description: 'Comma separated list of Practitioner IDs that in sum contain all MUST SUPPORT elements' + } } + } + + test from: :crd_client_fhir_api_search_test, + id: :crd_client_practitioner_role_organization_include_test, + title: 'Search by _id and _include organization', + config: { + options: { resource_type: 'PractitionerRole', search_type: 'organization_include' }, + inputs: { search_param_values: { + name: :practitioner_role_ids, + title: 'PractitionerRole IDs', + description: %( + Comma separated list of PractitionerRole IDs that in sum contain all MUST SUPPORT elements + ) + } } + } + + test from: :crd_client_fhir_api_search_test, + id: :crd_client_practitioner_role_practitioner_include_test, + title: 'Search by _id and _include practitioner', + config: { + options: { resource_type: 'PractitionerRole', search_type: 'practitioner_include' }, + inputs: { search_param_values: { + name: :practitioner_role_ids, + title: 'PractitionerRole IDs', + description: %( + Comma separated list of PractitionerRole IDs that in sum contain all MUST SUPPORT elements + ) + } } + } + + test from: :crd_client_fhir_api_validation_test, + config: { + options: { resource_type: 'PractitionerRole' } + } + end + + group do + title 'Location' + description %( + Verify the CRD client can perform the required FHIR interactions on the Location resource, and + validate any returned resources against the [CRD Location profile](https://hl7.org/fhir/us/davinci-crd/STU2/StructureDefinition-profile-location.html) + + Required Location resource FHIR interactions: + * SHOULD support `read` + + Resource Conformance: SHOULD + ) + optional + + test from: :crd_client_fhir_api_read_test, + optional: true, + config: { + options: { resource_type: 'Location' }, + inputs: { + resource_ids: { + name: :location_ids, + title: 'Location IDs', + description: 'Comma separated list of Location IDs that in sum contain all MUST SUPPORT elements' + } + } + } + + test from: :crd_client_fhir_api_validation_test, + config: { + options: { resource_type: 'Location' } + } + end + + group do + title 'MedicationRequest' + description %( + Verify the CRD client can perform the required FHIR interactions on the MedicationRequest resource, and + validate any returned resources against the [CRD MedicationRequest profile](https://hl7.org/fhir/us/davinci-crd/STU2/StructureDefinition-profile-medicationrequest.html) + + Required MedicationRequest resource FHIR interactions: + * SHOULD support `update` + + Resource Conformance: SHOULD + ) + optional + + test from: :crd_client_fhir_api_update_test, + optional: true, + config: { + options: { resource_type: 'MedicationRequest' }, + inputs: { + update_resources: { + name: :medication_request_update_resources, + title: 'MedicationRequest Resources' + } + } + } + end + + group do + title 'NutritionOrder' + description %( + Verify the CRD client can perform the required FHIR interactions on the NutritionOrder resource, and + validate any returned resources against the [CRD NutritionOrder profile](https://hl7.org/fhir/us/davinci-crd/STU2/StructureDefinition-profile-nutritionorder.html) + + Required NutritionOrder resource FHIR interactions: + * SHOULD support `update` + + Resource Conformance: SHOULD + ) + optional + + test from: :crd_client_fhir_api_update_test, + optional: true, + config: { + options: { resource_type: 'NutritionOrder' }, + inputs: { + update_resources: { + name: :nutrition_order_update_resources, + title: 'NutritionOrder Resources' + } + } + } + end + + group do + title 'Organization' + description %( + Verify the CRD client can perform the required FHIR interactions on the Organization resource, and + validate any returned resources against the [CRD Organization profile](https://hl7.org/fhir/us/davinci-crd/STU2/StructureDefinition-profile-organization.html) + + Required Organization resource FHIR interactions: + * SHOULD support `read` + + Resource Conformance: SHOULD + ) + optional + + test from: :crd_client_fhir_api_read_test, + optional: true, + config: { + options: { resource_type: 'Organization' }, + inputs: { + resource_ids: { + name: :organization_ids, + title: 'Organization IDs', + description: 'Comma separated list of Organization IDs that in sum contain all MUST SUPPORT elements' + } + } + } + + test from: :crd_client_fhir_api_validation_test, + config: { + options: { resource_type: 'Organization' } + } + end + + group do + title 'ServiceRequest' + description %( + Verify the CRD client can perform the required FHIR interactions on the ServiceRequest resource, and + validate any returned resources against the [CRD ServiceRequest profile](https://hl7.org/fhir/us/davinci-crd/STU2/StructureDefinition-profile-servicerequest.html) + + Required ServiceRequest resource FHIR interactions: + * SHOULD support `update` + + Resource Conformance: SHOULD + ) + optional + + test from: :crd_client_fhir_api_update_test, + optional: true, + config: { + options: { resource_type: 'ServiceRequest' }, + inputs: { + update_resources: { + name: :service_request_update_resources, + title: 'ServiceRequest Resources' + } + } + } + end + + group do + title 'ClaimResponse' + description %( + Verify the CRD client can perform the required FHIR interactions on the ClaimResponse resource, and + validate any returned resources against the [CRD ClaimResponse profile](https://hl7.org/fhir/us/davinci-hrex/STU1/StructureDefinition-hrex-claimresponse.html) + + Required ClaimResponse resource FHIR interactions: + * SHOULD support `create` + + Resource Conformance: SHOULD + ) + optional + + test from: :crd_client_fhir_api_create_test, + optional: true, + config: { + options: { resource_type: 'ClaimResponse' }, + inputs: { + create_resources: { + name: :claim_response_create_resources, + title: 'ClaimResponse Resources' + } + } + } + end + + group do + title 'Task' + description %( + Verify the CRD client can perform the required FHIR interactions on the Task resource, and + validate any returned resources against the [CRD Task profile](https://hl7.org/fhir/us/davinci-crd/STU2/StructureDefinition-profile-taskquestionnaire.html) + + Required Task resource FHIR interactions: + * SHOULD support `create` + + Resource Conformance: SHOULD + ) + optional + + test from: :crd_client_fhir_api_create_test, + optional: true, + config: { + options: { resource_type: 'Task' }, + inputs: { + create_resources: { + name: :task_create_resources, + title: 'Task Resources' + } + } + } + end + + group do + title 'VisionPrescription' + description %( + Verify the CRD client can perform the required FHIR interactions on the VisionPrescription resource, and + validate any returned resources against the [CRD VisionPrescription profile](https://hl7.org/fhir/us/davinci-crd/STU2/StructureDefinition-profile-visionprescription.html) + + Required VisionPrescription resource FHIR interactions: + * SHOULD support `update` + + Resource Conformance: SHOULD + ) + optional + + test from: :crd_client_fhir_api_update_test, + optional: true, + config: { + options: { resource_type: 'VisionPrescription' }, + inputs: { + update_resources: { + name: :vision_prescription_update_resources, + title: 'VisionPrescription Resources' + } + } + } + end + end + end +end diff --git a/lib/davinci_crd_test_kit/client_hook_request_validation.rb b/lib/davinci_crd_test_kit/client_hook_request_validation.rb new file mode 100644 index 0000000..e0a7677 --- /dev/null +++ b/lib/davinci_crd_test_kit/client_hook_request_validation.rb @@ -0,0 +1,15 @@ +require_relative 'hook_request_field_validation' + +module DaVinciCRDTestKit + module ClientHookRequestValidation + include DaVinciCRDTestKit::HookRequestFieldValidation + + def client_test? + true + end + + def server_test? + false + end + end +end diff --git a/lib/davinci_crd_test_kit/client_hooks_group.rb b/lib/davinci_crd_test_kit/client_hooks_group.rb new file mode 100644 index 0000000..0985287 --- /dev/null +++ b/lib/davinci_crd_test_kit/client_hooks_group.rb @@ -0,0 +1,706 @@ +require_relative 'client_tests/appointment_book_receive_request_test' +require_relative 'client_tests/encounter_start_receive_request_test' +require_relative 'client_tests/encounter_discharge_receive_request_test' +require_relative 'client_tests/order_dispatch_receive_request_test' +require_relative 'client_tests/order_select_receive_request_test' +require_relative 'client_tests/order_sign_receive_request_test' +require_relative 'client_tests/client_display_cards_attest' + +require_relative 'client_tests/decode_auth_token_test' +require_relative 'client_tests/retrieve_jwks_test' +require_relative 'client_tests/token_header_test' +require_relative 'client_tests/token_payload_test' + +require_relative 'client_tests/hook_request_required_fields_test' +require_relative 'client_tests/hook_request_optional_fields_test' + +require_relative 'client_tests/hook_request_valid_context_test' + +require_relative 'client_tests/hook_request_valid_prefetch_test' + +require_relative 'jwt_helper' +require_relative 'urls' + +module DaVinciCRDTestKit + class ClientHooksGroup < Inferno::TestGroup + title 'Hooks' + description <<~DESCRIPTION + This Group contains tests which verify that valid hook requests can be made for each of the following + [six hooks contained in the + implementation guide](https://hl7.org/fhir/us/davinci-crd/STU2/hooks.html): + * [appointment-book](https://hl7.org/fhir/us/davinci-crd/STU2/hooks.html#appointment-book) + * [encounter-start](https://hl7.org/fhir/us/davinci-crd/STU2/hooks.html#encounter-start) + * [encounter-discharge](https://hl7.org/fhir/us/davinci-crd/STU2/hooks.html#encounter-discharge) + * [order-select](https://hl7.org/fhir/us/davinci-crd/STU2/hooks.html#order-select) + * [order-dispatch](https://hl7.org/fhir/us/davinci-crd/STU2/hooks.html#order-dispatch) + * [order-sign](https://hl7.org/fhir/us/davinci-crd/STU2/hooks.html#order-sign) + + Each hook group contains a test which waits for incoming hook requests from the CRD client, and tests which + verify the incoming hook requests conform to the specific hook requirements specified the + CRD IG and the [CDS hooks spec](https://cds-hooks.hl7.org/2.0/). + + Each hook group tests the following: + * If the CRD Client can invoke the specific hook service request + * If the incoming hook request is properly authorized with a JWT Bearer token according to the [CDS Hooks authorization requirements](https://cds-hooks.hl7.org/2.0/#trusting-cds-clients) + * If the incoming hook request contains the required fields listed in the [CDS Hooks HTTP request requirements](https://cds-hooks.hl7.org/2.0/#http-request_1) + * OPTIONAL: If the incoming hook request contains the optional fields listed in the [CDS Hooks HTTP request requirements](https://cds-hooks.hl7.org/2.0/#http-request_1) + * If the hook request's `context` field is valid according to the specific `context` requirements defined for + each hook type + * OPTIONAL: If the incoming hook contains the optional `prefetch` field with valid resources + * If the client can properly display the cards returned as a result of the hook request + DESCRIPTION + id :crd_client_hooks + + input :iss, + title: 'JWT Issuer', + description: 'The `iss` claim of the JWT in the Authorization header ' \ + 'will be used to associate incoming requests with this test session' + + group do + title 'appointment-book' + description <<~DESCRIPTION + The appointment-book hook is invoked when the user is scheduling one or more future encounters/visits for the + patient. These tests are based on the following criteria: + * [CRD IG requirements for this hook](https://hl7.org/fhir/us/davinci-crd/STU2/hooks.html#appointment-book), + which include the profiles that are expected to be used for the resources resolved to by `context` FHIR ID + fields + * Specific [appointment-book `context` requirements](https://cds-hooks.hl7.org/hooks/appointment-book/2023SepSTU1Ballot/appointment-book/) + defined in the CDS Hooks specification + + This version of the CRD implementation guide refers to version 1.0 of the hook. + DESCRIPTION + + run_as_group + + test from: :crd_appointment_book_request + + test from: :crd_decode_auth_token, + config: { + requests: { + hook_request: { name: :appointment_book } + }, + outputs: { + auth_token: { name: :appointment_book_auth_token }, + auth_token_payload_json: { name: :appointment_book_auth_token_payload_json }, + auth_token_header_json: { name: :appointment_book_auth_token_header_json } + } + } + test from: :crd_retrieve_jwks, + config: { + inputs: { + auth_token_header_json: { name: :appointment_book_auth_token_header_json } + }, + outputs: { + crd_jwks_json: { name: :appointment_book_crd_jwks_json }, + crd_jwks_keys_json: { name: :appointment_book_crd_jwks_keys_json } + } + } + test from: :crd_token_header, + config: { + inputs: { + auth_token_header_json: { name: :appointment_book_auth_token_header_json }, + crd_jwks_keys_json: { name: :appointment_book_crd_jwks_keys_json } + }, + outputs: { + auth_token_jwk_json: { name: :appointment_book_auth_token_jwk_json } + } + } + test from: :crd_token_payload, + config: { + options: { hook_path: APPOINTMENT_BOOK_PATH }, + inputs: { + auth_token: { name: :appointment_book_auth_token }, + auth_token_jwk_json: { name: :appointment_book_auth_token_jwk_json } + } + } + + test from: :crd_hook_request_required_fields, + config: { + options: { + hook_path: APPOINTMENT_BOOK_PATH, + hook_name: 'appointment-book' + }, + requests: { + hook_request: { name: :appointment_book } + } + } + test from: :crd_hook_request_optional_fields, + config: { + outputs: { + client_fhir_server: { name: :appointment_book_client_fhir_server }, + client_access_token: { name: :appointment_book_client_access_token } + }, + requests: { + hook_request: { name: :appointment_book } + } + } + + test from: :crd_hook_request_valid_context, + config: { + inputs: { + client_fhir_server: { name: :appointment_book_client_fhir_server }, + client_access_token: { name: :appointment_book_client_access_token } + }, + options: { hook_name: 'appointment-book' }, + requests: { + hook_request: { name: :appointment_book } + } + } + + test from: :crd_hook_request_valid_prefetch, + config: { + options: { hook_name: 'appointment-book' }, + requests: { + hook_request: { name: :appointment_book } + } + } + + test from: :crd_card_display_attest_test, + config: { + inputs: { + selected_response_types: { name: :appointment_book_selected_response_types } + } + } + end + + group do + title 'encounter-start' + description <<~DESCRIPTION + The encounter-start hook is invoked when the user is initiating a new encounter. These tests are based on the + following criteria: + * [CRD IG requirements for this hook](https://hl7.org/fhir/us/davinci-crd/STU2/hooks.html#encounter-start), + which include the profiles that are expected to be used for the resources resolved to by `context` FHIR ID + fields + * Specific [encounter-start `context` requirements](https://cds-hooks.hl7.org/hooks/encounter-start/2023SepSTU1Ballot/encounter-start/) + defined in the CDS Hooks specification + + This version of the CRD implementation guide refers to version 1.0 of the hook. + DESCRIPTION + + run_as_group + + test from: :crd_encounter_start_request + + test from: :crd_decode_auth_token, + config: { + requests: { + hook_request: { name: :encounter_start } + }, + outputs: { + auth_token: { name: :encounter_start_auth_token }, + auth_token_payload_json: { name: :encounter_start_auth_token_payload_json }, + auth_token_header_json: { name: :encounter_start_auth_token_header_json } + } + } + test from: :crd_retrieve_jwks, + config: { + inputs: { + auth_token_header_json: { name: :encounter_start_auth_token_header_json } + }, + outputs: { + crd_jwks_json: { name: :encounter_start_crd_jwks_json }, + crd_jwks_keys_json: { name: :encounter_start_crd_jwks_keys_json } + } + } + test from: :crd_token_header, + config: { + inputs: { + auth_token_header_json: { name: :encounter_start_auth_token_header_json }, + crd_jwks_keys_json: { name: :encounter_start_crd_jwks_keys_json } + }, + outputs: { + auth_token_jwk_json: { name: :encounter_start_auth_token_jwk_json } + } + } + test from: :crd_token_payload, + config: { + options: { hook_path: ENCOUNTER_START_PATH }, + inputs: { + auth_token: { name: :encounter_start_auth_token }, + auth_token_jwk_json: { name: :encounter_start_auth_token_jwk_json } + } + } + + test from: :crd_hook_request_required_fields, + config: { + options: { + hook_path: ENCOUNTER_START_PATH, + hook_name: 'encounter-start' + }, + requests: { + hook_request: { name: :encounter_start } + } + } + test from: :crd_hook_request_optional_fields, + config: { + outputs: { + client_fhir_server: { name: :encounter_start_client_fhir_server }, + client_access_token: { name: :encounter_start_client_access_token } + }, + requests: { + hook_request: { name: :encounter_start } + } + } + + test from: :crd_hook_request_valid_context, + config: { + inputs: { + client_fhir_server: { name: :encounter_start_client_fhir_server }, + client_access_token: { name: :encounter_start_client_access_token } + }, + options: { hook_name: 'encounter-start' }, + requests: { + hook_request: { name: :encounter_start } + } + } + + test from: :crd_hook_request_valid_prefetch, + config: { + options: { hook_name: 'encounter-start' }, + requests: { + hook_request: { name: :encounter_start } + } + } + + test from: :crd_card_display_attest_test, + config: { + inputs: { + selected_response_types: { name: :encounter_start_selected_response_types } + } + } + end + + group do + title 'encounter-discharge' + description <<~DESCRIPTION + The encounter-discharge hook is invoked when the user is performing the discharge process for an encounter where + the notion of 'discharge' is relevant - typically an inpatient encounter. These tests are based on the + following criteria: + * [CRD IG requirements for this hook](https://hl7.org/fhir/us/davinci-crd/STU2/hooks.html#encounter-discharge), + which includes the profiles that are expected to be used for the resources resolved to by `context` + FHIR ID fields + * Specific [encounter-discharge `context` requirements](https://cds-hooks.hl7.org/hooks/encounter-discharge/2023SepSTU1Ballot/encounter-discharge/) + defined in the CDS Hooks specification + + This version of the CRD implementation guide refers to version 1.0 of the hook. + DESCRIPTION + + run_as_group + + test from: :crd_encounter_discharge_request + + test from: :crd_decode_auth_token, + config: { + requests: { + hook_request: { name: :encounter_discharge } + }, + outputs: { + auth_token: { name: :encounter_discharge_auth_token }, + auth_token_payload_json: { name: :encounter_discharge_auth_token_payload_json }, + auth_token_header_json: { name: :encounter_discharge_auth_token_header_json } + } + } + test from: :crd_retrieve_jwks, + config: { + inputs: { + auth_token_header_json: { name: :encounter_discharge_auth_token_header_json } + }, + outputs: { + crd_jwks_json: { name: :encounter_discharge_crd_jwks_json }, + crd_jwks_keys_json: { name: :encounter_discharge_crd_jwks_keys_json } + } + } + test from: :crd_token_header, + config: { + inputs: { + auth_token_header_json: { name: :encounter_discharge_auth_token_header_json }, + crd_jwks_keys_json: { name: :encounter_discharge_crd_jwks_keys_json } + }, + outputs: { + auth_token_jwk_json: { name: :encounter_discharge_auth_token_jwk_json } + } + } + test from: :crd_token_payload, + config: { + options: { hook_path: ENCOUNTER_DISCHARGE_PATH }, + inputs: { + auth_token: { name: :encounter_discharge_auth_token }, + auth_token_jwk_json: { name: :encounter_discharge_auth_token_jwk_json } + } + } + + test from: :crd_hook_request_required_fields, + config: { + options: { + hook_path: ENCOUNTER_DISCHARGE_PATH, + hook_name: 'encounter-discharge' + }, + requests: { + hook_request: { name: :encounter_discharge } + } + } + test from: :crd_hook_request_optional_fields, + config: { + outputs: { + client_fhir_server: { name: :encounter_discharge_client_fhir_server }, + client_access_token: { name: :encounter_discharge_client_access_token } + }, + requests: { + hook_request: { name: :encounter_discharge } + } + } + + test from: :crd_hook_request_valid_context, + config: { + inputs: { + client_fhir_server: { name: :encounter_discharge_client_fhir_server }, + client_access_token: { name: :encounter_discharge_client_access_token } + }, + options: { hook_name: 'encounter-discharge' }, + requests: { + hook_request: { name: :encounter_discharge } + } + } + + test from: :crd_hook_request_valid_prefetch, + config: { + options: { hook_name: 'encounter-discharge' }, + requests: { + hook_request: { name: :encounter_discharge } + } + } + + test from: :crd_card_display_attest_test, + config: { + inputs: { + selected_response_types: { name: :encounter_discharge_selected_response_types } + } + } + end + + group do + title 'order-select' + description <<~DESCRIPTION + The order-select hook fires when a clinician selects one or more orders to place for a patient, + (including orders for medications, procedures, labs and other orders). If supported by the CDS Client, this + hook may also be invoked each time the clinician selects a detail regarding the order. These tests are based on + the following criteria: + * [CRD IG requirements for this hook](https://hl7.org/fhir/us/davinci-crd/STU2/hooks.html#order-selecte), + which includes the profiles that are expected to be used for the resources resolved to by `context` + FHIR ID fields + * Specific [order-select `context` requirements](https://cds-hooks.hl7.org/hooks/order-select/2023SepSTU1Ballot/order-select/) + defined in the CDS Hooks specification + + This version of the CRD implementation guide refers to version 1.0 of the hook. + DESCRIPTION + run_as_group + + test from: :crd_order_select_request + + test from: :crd_decode_auth_token, + config: { + requests: { + hook_request: { name: :order_select } + }, + outputs: { + auth_token: { name: :order_select_auth_token }, + auth_token_payload_json: { name: :order_select_auth_token_payload_json }, + auth_token_header_json: { name: :order_select_auth_token_header_json } + } + } + + test from: :crd_retrieve_jwks, + config: { + inputs: { + auth_token_header_json: { name: :order_select_auth_token_header_json } + }, + outputs: { + crd_jwks_json: { name: :order_select_crd_jwks_json }, + crd_jwks_keys_json: { name: :order_select_crd_jwks_keys_json } + } + } + test from: :crd_token_header, + config: { + inputs: { + auth_token_header_json: { name: :order_select_auth_token_header_json }, + crd_jwks_keys_json: { name: :order_select_crd_jwks_keys_json } + }, + outputs: { + auth_token_jwk_json: { name: :order_select_auth_token_jwk_json } + } + } + test from: :crd_token_payload, + config: { + options: { hook_path: ORDER_SELECT_PATH }, + inputs: { + auth_token: { name: :order_select_auth_token }, + auth_token_jwk_json: { name: :order_select_auth_token_jwk_json } + } + } + + test from: :crd_hook_request_required_fields, + config: { + options: { + hook_path: ORDER_SELECT_PATH, + hook_name: 'order-select' + }, + requests: { + hook_request: { name: :order_select } + } + } + test from: :crd_hook_request_optional_fields, + config: { + outputs: { + client_fhir_server: { name: :order_select_client_fhir_server }, + client_access_token: { name: :order_select_client_access_token } + }, + requests: { + hook_request: { name: :order_select } + } + } + + test from: :crd_hook_request_valid_context, + config: { + inputs: { + client_fhir_server: { name: :order_select_client_fhir_server }, + client_access_token: { name: :order_select_client_access_token } + }, + options: { hook_name: 'order-select' }, + requests: { + hook_request: { name: :order_select } + } + } + + test from: :crd_hook_request_valid_prefetch, + config: { + options: { hook_name: 'order-select' }, + requests: { + hook_request: { name: :order_select } + } + } + + test from: :crd_card_display_attest_test, + config: { + inputs: { + selected_response_types: { name: :order_select_selected_response_types } + } + } + end + + group do + title 'order-dispatch' + description <<~DESCRIPTION + The order-dispatch hook fires when a practitioner is selecting a candidate performer for a pre-existing order + that was not tied to a specific performer. These tests are based on the following criteria: + * [CRD IG requirements for this hook](https://hl7.org/fhir/us/davinci-crd/STU2/hooks.html#order-dispatch), + which includes the profiles that are expected to be used for the resources resolved to by `context` + FHIR ID fields + * Specific [order-dispatch `context` requirements](https://cds-hooks.hl7.org/hooks/order-dispatch/2023SepSTU1Ballot/order-dispatch/) + defined in the CDS Hooks specification + + This version of the CRD implementation guide refers to version 1.0 of the hook. + DESCRIPTION + + run_as_group + + test from: :crd_order_dispatch_request + + test from: :crd_decode_auth_token, + config: { + requests: { + hook_request: { name: :order_dispatch } + }, + outputs: { + auth_token: { name: :order_dispatch_auth_token }, + auth_token_payload_json: { name: :order_dispatch_auth_token_payload_json }, + auth_token_header_json: { name: :order_dispatch_auth_token_header_json } + } + } + + test from: :crd_retrieve_jwks, + config: { + inputs: { + auth_token_header_json: { name: :order_dispatch_auth_token_header_json } + }, + outputs: { + crd_jwks_json: { name: :order_dispatch_crd_jwks_json }, + crd_jwks_keys_json: { name: :order_dispatch_crd_jwks_keys_json } + } + } + test from: :crd_token_header, + config: { + inputs: { + auth_token_header_json: { name: :order_dispatch_auth_token_header_json }, + crd_jwks_keys_json: { name: :order_dispatch_crd_jwks_keys_json } + }, + outputs: { + auth_token_jwk_json: { name: :order_dispatch_auth_token_jwk_json } + } + } + test from: :crd_token_payload, + config: { + options: { hook_path: ORDER_DISPATCH_PATH }, + inputs: { + auth_token: { name: :order_dispatch_auth_token }, + auth_token_jwk_json: { name: :order_dispatch_auth_token_jwk_json } + } + } + + test from: :crd_hook_request_required_fields, + config: { + options: { + hook_path: ORDER_DISPATCH_PATH, + hook_name: 'order-dispatch' + }, + requests: { + hook_request: { name: :order_dispatch } + } + } + test from: :crd_hook_request_optional_fields, + config: { + outputs: { + client_fhir_server: { name: :order_dispatch_client_fhir_server }, + client_access_token: { name: :order_dispatch_client_access_token } + }, + requests: { + hook_request: { name: :order_dispatch } + } + } + + test from: :crd_hook_request_valid_context, + config: { + inputs: { + client_fhir_server: { name: :order_dispatch_client_fhir_server }, + client_access_token: { name: :order_dispatch_client_access_token } + }, + options: { hook_name: 'order-dispatch' }, + requests: { + hook_request: { name: :order_dispatch } + } + } + + test from: :crd_hook_request_valid_prefetch, + config: { + options: { hook_name: 'order-dispatch' }, + requests: { + hook_request: { name: :order_dispatch } + } + } + + test from: :crd_card_display_attest_test, + config: { + inputs: { + selected_response_types: { name: :order_dispatch_selected_response_types } + } + } + end + + group do + title 'order-sign' + description <<~DESCRIPTION + The order-sign hook fires when a clinician is ready to sign one or more orders for a patient, (including orders + for medications, procedures, labs and other orders). These tests are based on the following criteria: + * [CRD IG requirements for this hook](https://hl7.org/fhir/us/davinci-crd/STU2/hooks.html#order-sign), + which includes the profiles that are expected to be used for the resources resolved to by `context` + FHIR ID fields + * Specific [order-sign `context` requirements](https://cds-hooks.org/hooks/order-sign/) + defined in the CDS Hooks specification + + This version of the CRD implementation guide refers to version 1.1 of the hook which, at the time of publication, + was not available as a snapshot. Therefore the preceding link refers to the CDS hooks current build. + DESCRIPTION + + run_as_group + + test from: :crd_order_sign_request + + test from: :crd_decode_auth_token, + config: { + requests: { + hook_request: { name: :order_sign } + }, + outputs: { + auth_token: { name: :order_sign_auth_token }, + auth_token_payload_json: { name: :order_sign_auth_token_payload_json }, + auth_token_header_json: { name: :order_sign_auth_token_header_json } + } + } + test from: :crd_retrieve_jwks, + config: { + inputs: { + auth_token_header_json: { name: :order_sign_auth_token_header_json } + }, + outputs: { + crd_jwks_json: { name: :order_sign_crd_jwks_json }, + crd_jwks_keys_json: { name: :order_sign_crd_jwks_keys_json } + } + } + test from: :crd_token_header, + config: { + inputs: { + auth_token_header_json: { name: :order_sign_auth_token_header_json }, + crd_jwks_keys_json: { name: :order_sign_crd_jwks_keys_json } + }, + outputs: { + auth_token_jwk_json: { name: :order_sign_auth_token_jwk_json } + } + } + test from: :crd_token_payload, + config: { + options: { hook_path: ORDER_SIGN_PATH }, + inputs: { + auth_token: { name: :order_sign_auth_token }, + auth_token_jwk_json: { name: :order_sign_auth_token_jwk_json } + } + } + + test from: :crd_hook_request_required_fields, + config: { + options: { + hook_path: ORDER_SIGN_PATH, + hook_name: 'order-sign' + }, + requests: { + hook_request: { name: :order_sign } + } + } + test from: :crd_hook_request_optional_fields, + config: { + outputs: { + client_fhir_server: { name: :order_sign_client_fhir_server }, + client_access_token: { name: :order_sign_client_access_token } + }, + requests: { + hook_request: { name: :order_sign } + } + } + + test from: :crd_hook_request_valid_context, + config: { + inputs: { + client_fhir_server: { name: :order_sign_client_fhir_server }, + client_access_token: { name: :order_sign_client_access_token } + }, + options: { hook_name: 'order-sign' }, + requests: { + hook_request: { name: :order_sign } + } + } + + test from: :crd_hook_request_valid_prefetch, + config: { + options: { hook_name: 'order-sign' }, + requests: { + hook_request: { name: :order_sign } + } + } + + test from: :crd_card_display_attest_test, + config: { + inputs: { + selected_response_types: { name: :order_sign_selected_response_types } + } + } + end + end +end diff --git a/lib/davinci_crd_test_kit/client_tests/appointment_book_receive_request_test.rb b/lib/davinci_crd_test_kit/client_tests/appointment_book_receive_request_test.rb new file mode 100644 index 0000000..98d2cfc --- /dev/null +++ b/lib/davinci_crd_test_kit/client_tests/appointment_book_receive_request_test.rb @@ -0,0 +1,71 @@ +require_relative '../urls' + +module DaVinciCRDTestKit + class AppointmentBookReceiveRequestTest < Inferno::Test + include URLs + + id :crd_appointment_book_request + title 'Request received for appointment-book hook' + description %( + This test waits for an incoming [appointment-book](https://hl7.org/fhir/us/davinci-crd/STU2/hooks.html#appointment-book) + hook request and responds to the client with the response types selected as an input. This hook is a 'primary' + hook, meaning that CRD Servers SHALL, at minimum, return a [Coverage Information](https://hl7.org/fhir/us/davinci-crd/STU2/StructureDefinition-ext-coverage-information.html) + system action for these hooks, even if the response indicates that further information is needed or that the + level of detail provided is insufficient to determine coverage. + ) + receives_request :appointment_book + + input :iss + input :appointment_book_selected_response_types, + title: 'Response types to return from appointment-book hook requests', + description: %( + Select the cards/action response types that the Inferno hook request endpoints will return. The default + response type that will be returned for this hook is the `Coverage Information` card type. + ), + type: 'checkbox', + default: ['coverage_information'], + options: { + list_options: [ + { + label: 'External Reference', + value: 'external_reference' + }, + { + label: 'Instructions', + value: 'instructions' + }, + { + label: 'Coverage Information', + value: 'coverage_information' + }, + { + label: 'Request Form Completion', + value: 'request_form_completion' + }, + { + label: 'Create/Update Coverage Information', + value: 'create_update_coverage_info' + }, + { + label: 'Launch SMART Application', + value: 'launch_smart_app' + } + ] + } + + run do + wait( + identifier: "appointment-book #{iss}", + message: %( + **Appointment Book CDS Service Test**: + + Invoke the appointment-book hook and send a request to: + + `#{appointment_book_url}` + + Inferno will process the request and return CDS cards if successful. + ) + ) + end + end +end diff --git a/lib/davinci_crd_test_kit/client_tests/client_display_cards_attest.rb b/lib/davinci_crd_test_kit/client_tests/client_display_cards_attest.rb new file mode 100644 index 0000000..7938d84 --- /dev/null +++ b/lib/davinci_crd_test_kit/client_tests/client_display_cards_attest.rb @@ -0,0 +1,48 @@ +require_relative '../urls' + +module DaVinciCRDTestKit + class ClientCardDisplayAttest < Inferno::Test + include URLs + + id :crd_card_display_attest_test + title 'Check that Cards returned are displayed to the user' + + input :selected_response_types, + type: 'checkbox' + + def format_selected_response_types + selected_response_types + .map do |response_type| + response_type_string = + response_type.split('_') + .map(&:capitalize) + .join(' ') + .prepend('- ') + .sub('Smart', 'SMART') + .sub('Create Update', 'Create/Update') + .sub('Companions Prerequisites', 'Companions/Prerequisites') + response_type_string + end + .join("\n") + end + + run do + identifier = SecureRandom.hex(32) + wait( + identifier:, + message: <<~MESSAGE + **Approval Workflow Test**: + + I attest that the following CDS response types were returned and that the client system displays + each of the CDS Service Cards to the user: + + #{format_selected_response_types} + + [Click here](#{resume_pass_url}?token=#{identifier}) if the above statement is **true**. + + [Click here](#{resume_fail_url}?token=#{identifier}) if the above statement is **false**. + MESSAGE + ) + end + end +end diff --git a/lib/davinci_crd_test_kit/client_tests/client_fhir_api_create_test.rb b/lib/davinci_crd_test_kit/client_tests/client_fhir_api_create_test.rb new file mode 100644 index 0000000..45f838f --- /dev/null +++ b/lib/davinci_crd_test_kit/client_tests/client_fhir_api_create_test.rb @@ -0,0 +1,40 @@ +module DaVinciCRDTestKit + class ClientFHIRApiCreateTest < Inferno::Test + id :crd_client_fhir_api_create_test + title 'Create Interaction' + description %( + Verify that the CRD client supports the create interaction for the given resource. The capabilities required + by each resource can be found here: https://hl7.org/fhir/us/davinci-crd/CapabilityStatement-crd-client.html#resourcesSummary1 + ) + + input :create_resources, + type: 'textarea', + description: + 'Provide a list of resources to create. e.g., [json_resource_1, json_resource_2]' + + def resource_type + config.options[:resource_type] + end + + run do + assert_valid_json(create_resources) + create_resources_list = JSON.parse(create_resources) + skip_if(!create_resources_list.is_a?(Array), 'Resources to create not inputted in list format, skipping test.') + + valid_create_resources = + create_resources_list + .compact_blank + .map { |resource| FHIR.from_contents(resource.to_json) } + .select { |resource| resource.resourceType == resource_type } + .select { |resource| resource_is_valid?(resource:) } + + skip_if(valid_create_resources.blank?, + %(No valid #{resource_type} resources were provided to send in Create requests, skipping test.)) + + valid_create_resources.each do |create_resource| + fhir_create(create_resource) + assert_response_status(201) + end + end + end +end diff --git a/lib/davinci_crd_test_kit/client_tests/client_fhir_api_read_test.rb b/lib/davinci_crd_test_kit/client_tests/client_fhir_api_read_test.rb new file mode 100644 index 0000000..466ea3c --- /dev/null +++ b/lib/davinci_crd_test_kit/client_tests/client_fhir_api_read_test.rb @@ -0,0 +1,39 @@ +module DaVinciCRDTestKit + class ClientFHIRApiReadTest < Inferno::Test + id :crd_client_fhir_api_read_test + title 'Read Interaction' + description %( + Verify that the CRD client supports the read interaction for the given resource. The capabilities required by + each resource can be found here: https://hl7.org/fhir/us/davinci-crd/CapabilityStatement-crd-client.html#resourcesSummary1 + ) + + input :resource_ids + + def resource_type + config.options[:resource_type] + end + + def no_resources_skip_message + "No #{resource_type} resource ids were provided, skipping test. " + end + + def bad_resource_id_message(expected_id) + "Expected resource to have id: `#{expected_id}`, but found `#{resource.id}`" + end + + run do + skip_if resource_ids.blank?, no_resources_skip_message + + resource_id_list = resource_ids.split(',').map(&:strip) + assert resource_id_list.present?, "No #{resource_type} id provided." + + resource_id_list.each do |resource_id_to_read| + fhir_read resource_type, resource_id_to_read, tags: [resource_type, 'read'] + + assert_response_status(200) + assert_resource_type(resource_type) + assert resource.id.present? && resource.id == resource_id_to_read, bad_resource_id_message(resource_id_to_read) + end + end + end +end diff --git a/lib/davinci_crd_test_kit/client_tests/client_fhir_api_search_test.rb b/lib/davinci_crd_test_kit/client_tests/client_fhir_api_search_test.rb new file mode 100644 index 0000000..a43dd5f --- /dev/null +++ b/lib/davinci_crd_test_kit/client_tests/client_fhir_api_search_test.rb @@ -0,0 +1,232 @@ +module DaVinciCRDTestKit + class ClientFHIRApiSearchTest < Inferno::Test + id :crd_client_fhir_api_search_test + title 'Search Interaction' + description %( + Verify that the CRD client supports the specified search interaction for the given resource. The capabilities + required by each resource can be found here: https://hl7.org/fhir/us/davinci-crd/CapabilityStatement-crd-client.html#resourcesSummary1 + ) + + input :search_param_values, + optional: true + + attr_accessor :successful_search + + def resource_type + config.options[:resource_type] + end + + def search_type + config.options[:search_type] + end + + def include_searches + ['organization_include', 'practitioner_include', 'location_include'] + end + + def reference_search_paramaters + ['organization', 'practitioner', 'patient'] + end + + def bad_resource_id_message(expected_id, actual_id) + "Expected resource to have id: `#{expected_id}`, but found `#{actual_id}`" + end + + def perform_fhir_search(search_params, tags) + fhir_search(resource_type, params: search_params, tags:) + assert_response_status(200) + assert_resource_type(:bundle) + resource + end + + def status_search_result_check(bundle, status) + return if bundle.entry.empty? + + self.successful_search = true + + bundle.entry + .reject { |entry| entry&.resource&.resourceType == 'OperationOutcome' } + .map(&:resource) + .each do |resource| + assert_resource_type(resource_type, resource:) + assert(resource.status == status, %( + Each #{resource_type} resource in search result bundle should have a status of `#{status}`, instead got: + `#{resource.status}` for resource with id: `#{resource.id}` + )) + end + end + + def check_id_search_result_entry(bundle_entry, search_id, entry_resource_type) + assert_resource_type(entry_resource_type, resource: bundle_entry) + + assert bundle_entry.id.present?, "Expected id field in returned #{entry_resource_type} resource" + + assert bundle_entry.id == search_id, + bad_resource_id_message(search_id, bundle_entry.id) + end + + def id_search_result_check(bundle, search_id) + warning do + assert bundle.entry.any?, + "Search result bundle is empty for #{resource_type} _id search with an id of `#{search_id}`" + end + return if bundle.entry.empty? + + self.successful_search = true + + bundle.entry + .reject { |entry| entry&.resource&.resourceType == 'OperationOutcome' } + .map(&:resource) + .each do |resource| + check_id_search_result_entry(resource, search_id, resource_type) + end + end + + def check_include_reference(base_resource_entry, include_resource_id, include_resource_type) + base_resource_references = Array.wrap(get_reference_field(include_resource_type, base_resource_entry)).compact + + assert(base_resource_references.present?, %( + #{resource_type} resource with id #{base_resource_entry.id} did not include the field that references a + #{include_resource_type} resource} + )) + + base_resource_reference_match_found = base_resource_references.any? do |base_resource_reference| + base_resource_reference.reference_id == include_resource_id + end + + assert(base_resource_reference_match_found, %( + The #{resource_type} resource in search result bundle with id #{base_resource_entry.id} did not have a + #{include_resource_type} reference with an id of `#{include_resource_id}`.` + )) + end + + def include_search_result_check(bundle, search_id, included_resource_type) # rubocop:disable Metrics/CyclomaticComplexity + warning do + assert bundle.entry.any?, + "Search result bundle is empty for #{resource_type} _include #{search_type} search with an id + of `#{search_id}`" + end + return if bundle.entry.empty? + + self.successful_search = true + + base_resource_entry_list = bundle.entry.select do |entry| + entry.resource&.resourceType == resource_type + end + + assert(base_resource_entry_list.length == 1, %( + The #{included_resource_type} _include search for #{resource_type} resource with id #{search_id} + should include exactly 1 #{resource_type} resource, instead got #{base_resource_entry_list.length}. + )) + + base_resource_entry = base_resource_entry_list.first.resource + + bundle.entry + .map(&:resource) + .each do |resource| + entry_resource_type = resource.resourceType + + if entry_resource_type == resource_type + check_id_search_result_entry(resource, search_id, entry_resource_type) + elsif entry_resource_type != 'OperationOutcome' + entry_resource_type = included_resource_type.capitalize + assert_resource_type(entry_resource_type, resource:) + + included_resource_id = resource.id + assert included_resource_id.present?, "Expected id field in returned #{entry_resource_type} resource" + check_include_reference(base_resource_entry, included_resource_id, included_resource_type) + end + end + end + + def get_reference_field(reference_type, entry) + case reference_type + when 'patient' + entry.beneficiary + when 'practitioner' + entry.practitioner + when 'organization' + if resource_type == 'Encounter' + entry.serviceProvider + else + entry.organization + end + when 'location' + locations = entry.location + locations.map(&:location) + end + end + + def reference_search_result_check(bundle, reference_id, reference_type) + warning do + assert bundle.entry.any?, %( + Search result bundle is empty for #{resource_type} #{reference_type} search with a #{reference_type} id + `#{reference_id}` + ) + end + return if bundle.entry.empty? + + self.successful_search = true + + bundle.entry + .reject { |entry| entry&.resource&.resourceType == 'OperationOutcome' } + .map(&:resource) + .each do |resource| + assert_resource_type(resource_type, resource:) + + entry_reference_field = get_reference_field(reference_type, resource) + assert( + entry_reference_field.present?, + %( + #{resource_type} resource with id #{resource.id} did not include the field that references + a #{reference_type} resource + ) + ) + + entry_reference_id = entry_reference_field.reference_id + assert( + entry_reference_id == reference_id, + %( + The #{resource_type} resource in search result bundle with id #{resource.id} should have a + #{reference_type} reference with an id of `#{reference_id}`, instead got: `#{entry_reference_id}` + ) + ) + end + end + + run do + if search_type == 'status' + coverage_status = ['active', 'cancelled', 'draft', 'entered-in-error'] + coverage_status.each do |status| + bundle = perform_fhir_search({ status: }, [resource_type, 'status_search']) + status_search_result_check(bundle, status) + end + else + skip_if search_param_values.blank?, 'No search parameters passed in, skipping test.' + + search_id_list = search_param_values.split(',').map(&:strip) + search_id_list.each do |search_id| + if search_type == '_id' + bundle = perform_fhir_search({ _id: search_id }, [resource_type, 'id_search']) + id_search_result_check(bundle, search_id) + elsif reference_search_paramaters.include?(search_type) + search_params = {} + search_params[search_type] = search_id + bundle = perform_fhir_search(search_params, [resource_type, "#{search_type}_search"]) + reference_search_result_check(bundle, search_id, search_type) + elsif include_searches.include?(search_type) + include_resource_type = search_type.gsub('_include', '') + bundle = perform_fhir_search({ _id: search_id, _include: "#{resource_type}:#{include_resource_type}" }, + [resource_type, "include_#{include_resource_type}_search"]) + include_search_result_check(bundle, search_id, include_resource_type) + else + raise StandardError, + 'Passed in search_type does not match to any of the search types handled by this search test.' + end + end + end + skip_if !successful_search, + 'No resources returned in any of the search result bundles.' + end + end +end diff --git a/lib/davinci_crd_test_kit/client_tests/client_fhir_api_update_test.rb b/lib/davinci_crd_test_kit/client_tests/client_fhir_api_update_test.rb new file mode 100644 index 0000000..892cd73 --- /dev/null +++ b/lib/davinci_crd_test_kit/client_tests/client_fhir_api_update_test.rb @@ -0,0 +1,40 @@ +module DaVinciCRDTestKit + class ClientFHIRApiUpdateTest < Inferno::Test + id :crd_client_fhir_api_update_test + title 'Update Interaction' + description %( + Verify that the CRD client supports the update interaction for the given resource. The capabilities required by + each resource can be found here: https://hl7.org/fhir/us/davinci-crd/CapabilityStatement-crd-client.html#resourcesSummary1 + ) + + input :update_resources, + type: 'textarea', + description: + 'Provide a list of resources to update. e.g., [json_resource_1, json_resource_2]' + + def resource_type + config.options[:resource_type] + end + + run do + assert_valid_json(update_resources) + update_resources_list = JSON.parse(update_resources) + skip_if(!update_resources_list.is_a?(Array), 'Resources to update not inputted in list format, skipping test.') + + valid_update_resources = + update_resources_list + .compact_blank + .map { |resource| FHIR.from_contents(resource.to_json) } + .select { |resource| resource.resourceType == resource_type } + .select { |resource| resource_is_valid?(resource:) } + + skip_if(valid_update_resources.blank?, + %(No valid #{resource_type} resources were provided to send in Update requests, skipping test.)) + + valid_update_resources.each do |update_resource| + fhir_update(update_resource, update_resource.id) + assert_response_status([200, 201]) + end + end + end +end diff --git a/lib/davinci_crd_test_kit/client_tests/client_fhir_api_validation_test.rb b/lib/davinci_crd_test_kit/client_tests/client_fhir_api_validation_test.rb new file mode 100644 index 0000000..93fc75d --- /dev/null +++ b/lib/davinci_crd_test_kit/client_tests/client_fhir_api_validation_test.rb @@ -0,0 +1,60 @@ +module DaVinciCRDTestKit + class ClientFHIRApiValidationTest < Inferno::Test + id :crd_client_fhir_api_validation_test + title 'FHIR Resource Validation' + description %( + Verify that the given resources returned from the previous client API interactions are valid resources. Each + resource is validated against its corresponding [CRD resorce profile](https://hl7.org/fhir/us/davinci-crd/STU2/artifacts.html). + ) + + def resource_type + config.options[:resource_type] + end + + def structure_definition_map + { + 'Practitioner' => 'http://hl7.org/fhir/us/davinci-crd/StructureDefinition/profile-practitioner', + 'PractitionerRole' => 'http://hl7.org/fhir/us/core/StructureDefinition/us-core-practitionerrole', + 'Patient' => 'http://hl7.org/fhir/us/davinci-crd/StructureDefinition/profile-patient', + 'Encounter' => 'http://hl7.org/fhir/us/davinci-crd/StructureDefinition/profile-encounter', + 'Coverage' => 'http://hl7.org/fhir/us/davinci-crd/StructureDefinition/profile-coverage', + 'Device' => 'http://hl7.org/fhir/us/davinci-crd/StructureDefinition/profile-device', + 'Location' => 'http://hl7.org/fhir/us/davinci-crd/StructureDefinition/profile-location', + 'Organization' => 'http://hl7.org/fhir/us/davinci-crd/StructureDefinition/profile-organization' + }.freeze + end + + def profile_url + structure_definition_map[resource_type] + end + + run do + load_tagged_requests(resource_type) + skip_if requests.empty?, 'No FHIR api requests were made' + + requests.keep_if { |req| req.status == 200 } + skip_if(requests.blank?, + 'There were no successful FHIR API requests made in previous tests to use in validation.') + + validated_resources = + requests + .map(&:resource) + .compact + .flat_map { |resource| resource.is_a?(FHIR::Bundle) ? resource.entry.map(&:resource) : resource } + .select { |resource| resource.resourceType == resource_type } + .uniq { |resource| resource.to_reference.reference } + .each { |resource| resource_is_valid?(resource:, profile_url:) } + + skip_if(validated_resources.blank?, + %(No #{resource_type} resources were returned from any of the FHIR API requests made in previous tests + that could be validated.)) + + validation_error_count = messages.count { |msg| msg[:type] == 'error' } + assert(validation_error_count.zero?, + %(#{validation_error_count}/#{validated_resources.length} #{resource_type} resources returned from previous + test's FHIR API requests failed validation.)) + + skip_if validated_resources.blank?, 'No FHIR resources were made in previous tests that could be validated.' + end + end +end diff --git a/lib/davinci_crd_test_kit/client_tests/decode_auth_token_test.rb b/lib/davinci_crd_test_kit/client_tests/decode_auth_token_test.rb new file mode 100644 index 0000000..1cfa76b --- /dev/null +++ b/lib/davinci_crd_test_kit/client_tests/decode_auth_token_test.rb @@ -0,0 +1,40 @@ +module DaVinciCRDTestKit + class DecodeAuthTokenTest < Inferno::Test + id :crd_decode_auth_token + title 'Bearer token can be decoded' + description %( + Verify that the Bearer token is a properly constructed JWT. As per the [CDS hooks specification](https://cds-hooks.hl7.org/2.0#trusting-cds-clients), + each time a CDS Client transmits a request to a CDS Service which requires authentication, the request MUST + include an Authorization header presenting the JWT as a "Bearer" token. + ) + + output :auth_token, :auth_token_payload_json, :auth_token_header_json + + uses_request :hook_request + + run do + authorization_header = request.request_header('Authorization')&.value + skip_if authorization_header.blank?, 'Request does not include an Authorization header' + + assert(authorization_header.start_with?('Bearer '), + 'Authorization token must be a JWT presented as a `Bearer` token') + + auth_token = authorization_header.delete_prefix('Bearer ') + output(auth_token:) + + begin + payload, header = + JWT.decode( + auth_token, + nil, + false + ) + + output auth_token_payload_json: payload.to_json, + auth_token_header_json: header.to_json + rescue StandardError => e + assert false, "Token is not a properly constructed JWT: #{e.message}" + end + end + end +end diff --git a/lib/davinci_crd_test_kit/client_tests/encounter_discharge_receive_request_test.rb b/lib/davinci_crd_test_kit/client_tests/encounter_discharge_receive_request_test.rb new file mode 100644 index 0000000..d9586c7 --- /dev/null +++ b/lib/davinci_crd_test_kit/client_tests/encounter_discharge_receive_request_test.rb @@ -0,0 +1,68 @@ +require_relative '../urls' + +module DaVinciCRDTestKit + class EncounterDischargeReceiveRequestTest < Inferno::Test + include URLs + + id :crd_encounter_discharge_request + title 'Request received for encounter-discharge hook' + description %( + This test waits for an incoming [encounter-discharge](https://hl7.org/fhir/us/davinci-crd/STU2/hooks.html#encounter-discharge) + hook request and responds to the client with the response types selected as an input. + ) + receives_request :encounter_discharge + + input :iss + input :encounter_discharge_selected_response_types, + title: 'Response types to return from encounter-discharge hook requests', + description: %( + Select the cards/action response types that the Inferno hook request endpoints will return. The default + response type that will be returned for this hook is the `Instructions` card type. + ), + type: 'checkbox', + default: ['instructions'], + options: { + list_options: [ + { + label: 'External Reference', + value: 'external_reference' + }, + { + label: 'Instructions', + value: 'instructions' + }, + { + label: 'Coverage Information', + value: 'coverage_information' + }, + { + label: 'Request Form Completion', + value: 'request_form_completion' + }, + { + label: 'Create/Update Coverage Information', + value: 'create_update_coverage_info' + }, + { + label: 'Launch SMART Application', + value: 'launch_smart_app' + } + ] + } + + run do + wait( + identifier: "encounter-discharge #{iss}", + message: %( + **Encounter Discharge CDS Service Test**: + + Invoke the encounter-discharge hook and send a request to: + + `#{encounter_discharge_url}` + + Inferno will process the request and return CDS cards if successful. + ) + ) + end + end +end diff --git a/lib/davinci_crd_test_kit/client_tests/encounter_start_receive_request_test.rb b/lib/davinci_crd_test_kit/client_tests/encounter_start_receive_request_test.rb new file mode 100644 index 0000000..30eaa6b --- /dev/null +++ b/lib/davinci_crd_test_kit/client_tests/encounter_start_receive_request_test.rb @@ -0,0 +1,68 @@ +require_relative '../urls' + +module DaVinciCRDTestKit + class EncounterStartReceiveRequestTest < Inferno::Test + include URLs + + id :crd_encounter_start_request + title 'Request received for encounter-start hook' + description %( + This test waits for an incoming [encounter-start](https://hl7.org/fhir/us/davinci-crd/STU2/hooks.html#encounter-start) + hook request and responds to the client with the response types selected as an input. + ) + receives_request :encounter_start + + input :iss + input :encounter_start_selected_response_types, + title: 'Response types to return from encounter-start hook requests', + description: %( + Select the cards/action response types that the Inferno hook request endpoints will return. The default + response type that will be returned for this hook is the `Instructions` card type. + ), + type: 'checkbox', + default: ['instructions'], + options: { + list_options: [ + { + label: 'External Reference', + value: 'external_reference' + }, + { + label: 'Instructions', + value: 'instructions' + }, + { + label: 'Coverage Information', + value: 'coverage_information' + }, + { + label: 'Request Form Completion', + value: 'request_form_completion' + }, + { + label: 'Create/Update Coverage Information', + value: 'create_update_coverage_info' + }, + { + label: 'Launch SMART Application', + value: 'launch_smart_app' + } + ] + } + + run do + wait( + identifier: "encounter-start #{iss}", + message: %( + **Encounter Start CDS Service Test**: + + Invoke the encounter-start hook and send a request to: + + `#{encounter_start_url}` + + Inferno will process the request and return CDS cards if successful. + ) + ) + end + end +end diff --git a/lib/davinci_crd_test_kit/client_tests/hook_request_optional_fields_test.rb b/lib/davinci_crd_test_kit/client_tests/hook_request_optional_fields_test.rb new file mode 100644 index 0000000..8287302 --- /dev/null +++ b/lib/davinci_crd_test_kit/client_tests/hook_request_optional_fields_test.rb @@ -0,0 +1,41 @@ +require_relative '../client_hook_request_validation' + +module DaVinciCRDTestKit + class HookRequestOptionalFieldsTest < Inferno::Test + include DaVinciCRDTestKit::ClientHookRequestValidation + + id :crd_hook_request_optional_fields + title 'Hook request contains optional fields' + description %( + Under the [CDS hooks HTTP Request section](https://cds-hooks.hl7.org/2.0/#http-request_1), the specification + requires that a CDS service request SHALL include a JSON POST body which MAY contain the following optional input + fields: + * `fhirServer` - *URL* + * `fhirAuthorization` - *object* + * `prefetch` - *object* + + This test checks for the precense of these fields and if they are of the correct type. This test is optional and + will not fail if the hook request does not contain an optional field, it only produces an informational message. + If the client provides its FHIR server URL in the `fhirServer` field, and it's authorization token in the + `fhirAuthorization` field object, they will be produced as an output from this test to be used in + subsequent tests. + ) + optional + + output :client_fhir_server + output :client_access_token, + optional: true + + uses_request :hook_request + + run do + assert_valid_json(request.request_body) + request_body = JSON.parse(request.request_body) + + client_fhir_server = hook_request_optional_fields_check(request_body) + + output client_fhir_server: client_fhir_server[:fhir_server_uri], + client_access_token: client_fhir_server[:fhir_access_token] + end + end +end diff --git a/lib/davinci_crd_test_kit/client_tests/hook_request_required_fields_test.rb b/lib/davinci_crd_test_kit/client_tests/hook_request_required_fields_test.rb new file mode 100644 index 0000000..30e6f6b --- /dev/null +++ b/lib/davinci_crd_test_kit/client_tests/hook_request_required_fields_test.rb @@ -0,0 +1,40 @@ +require_relative '../client_hook_request_validation' + +module DaVinciCRDTestKit + class HookRequestRequiredFieldsTest < Inferno::Test + include DaVinciCRDTestKit::ClientHookRequestValidation + include URLs + + id :crd_hook_request_required_fields + title 'Hook request contains required fields' + description %( + Under the [CDS hooks HTTP Request section](https://cds-hooks.hl7.org/2.0/#http-request_1), the specification + requires that a CDS service request SHALL include a JSON POST body with the following input fields: + * `hook` - *string* + * `hookInstance` - *string* + * `context` - *object* + + Additionally, if the optional `fhirAuthorization` field is present, then the `fhirServer` field is required. + + This test also checks that the `hook` field contains the correct CDS service name that the CDS client is sending + a request for + ) + + uses_request :hook_request + + def hook_url + base_url + config.options[:hook_path] + end + + def hook_name + config.options[:hook_name] + end + + run do + assert_valid_json(request.request_body) + request_body = JSON.parse(request.request_body) + + hook_request_required_fields_check(request_body, hook_name) + end + end +end diff --git a/lib/davinci_crd_test_kit/client_tests/hook_request_valid_context_test.rb b/lib/davinci_crd_test_kit/client_tests/hook_request_valid_context_test.rb new file mode 100644 index 0000000..239c6f5 --- /dev/null +++ b/lib/davinci_crd_test_kit/client_tests/hook_request_valid_context_test.rb @@ -0,0 +1,63 @@ +require_relative '../client_hook_request_validation' +module DaVinciCRDTestKit + class HookRequestValidContextTest < Inferno::Test + include URLs + include ClientHookRequestValidation + + id :crd_hook_request_valid_context + title 'Hook contains valid context' + description %( + As stated in the [CDS hooks specification](https://cds-hooks.hl7.org/2.0#http-request), a CDS service request's + `context` field contains hook-specific contextual data that the CDS service will need. The context is specified + in the hook definition to guide developers on the information available at the point in the workflow when the hook + is triggered. + + The `context` requirements for each [hook specified in the CRD IG](https://hl7.org/fhir/us/davinci-crd/STU2/hooks.html) + can be found below: + * [appointment-book](https://cds-hooks.hl7.org/hooks/appointment-book/2023SepSTU1Ballot/appointment-book/) + * [encounter-start](https://cds-hooks.hl7.org/hooks/encounter-start/2023SepSTU1Ballot/encounter-start/) + * [encounter-discharge](https://cds-hooks.hl7.org/hooks/encounter-discharge/2023SepSTU1Ballot/encounter-discharge/) + * [order-select](https://cds-hooks.hl7.org/hooks/order-select/2023SepSTU1Ballot/order-select/) + * [order-dispatch](https://cds-hooks.hl7.org/hooks/order-dispatch/2023SepSTU1Ballot/order-dispatch/) + * [order-sign](https://cds-hooks.org/hooks/order-sign/) + + This test performs the following: + * Verifies that the incoming hook request's `context` field contains the fields required by each hook and + that they are in the correct format + * Checks the optional fields and ensures they are in the correct format + * Validates any resources contained in a `context` field that contains a Bundle or FHIR resource + * Makes FHIR requests for any `context` fields that contain an id or reference and validates each resource + response against its corresponding CRD resource profile + * Check some specific `context` requirements for hooks that have special requirements for certain fields + + The client must provide its FHIR server URL and access token in the hook request in order to run + this test. + ) + uses_request :hook_request + + input :client_fhir_server + input :client_access_token, + optional: true + + fhir_client do + url :client_fhir_server + bearer_token :client_access_token + end + + def hook_name + config.options[:hook_name] + end + + run do + assert_valid_json(request.request_body) + request_body = JSON.parse(request.request_body) + + hook_context = request_body['context'] + + assert(hook_context, 'Hook request does not contain required `context` field') + + hook_request_context_check(hook_context, hook_name) + no_error_validation('Context is not valid.') + end + end +end diff --git a/lib/davinci_crd_test_kit/client_tests/hook_request_valid_prefetch_test.rb b/lib/davinci_crd_test_kit/client_tests/hook_request_valid_prefetch_test.rb new file mode 100644 index 0000000..04b4654 --- /dev/null +++ b/lib/davinci_crd_test_kit/client_tests/hook_request_valid_prefetch_test.rb @@ -0,0 +1,151 @@ +require_relative '../urls' + +module DaVinciCRDTestKit + class HookRequestValidPrefetchTest < Inferno::Test + include URLs + + id :crd_hook_request_valid_prefetch + title 'Hook contains valid prefetch response' + description %( + As stated in the [CDS hooks specification](https://cds-hooks.hl7.org/2.0#http-request), a CDS service request's + `prefetch` field is an optional field that contains key/value pairs of FHIR queries that the service is requesting + the CDS Client to perform and provide on each service call. The key is a string that describes the type of data + being requested and the value is a string representing the FHIR query. See [Prefetch Template](https://cds-hooks.hl7.org/2.0#prefetch-template) + for more information about how the `prefetch` formatting works. + + This test verifies that the incoming hook request's `prefetch` field is in a valid JSON format and validates each + contained resource against its corresponding CRD resource profile. This test is optional and will be skipped if no + `prefetch` field is contained in the hook request. + ) + optional + + uses_request :hook_request + + def hook_name + config.options[:hook_name] + end + + def cds_services_json + JSON.parse(File.read(File.join( + __dir__, '..', 'routes', 'cds-services.json' + )))['services'] + end + + def structure_definition_map + { + 'Practitioner' => 'http://hl7.org/fhir/us/davinci-crd/StructureDefinition/profile-practitioner', + 'PractitionerRole' => 'http://hl7.org/fhir/us/core/StructureDefinition/us-core-practitionerrole', + 'Patient' => 'http://hl7.org/fhir/us/davinci-crd/StructureDefinition/profile-patient', + 'Coverage' => 'http://hl7.org/fhir/us/davinci-crd/StructureDefinition/profile-coverage', + 'RelatedPerson' => 'http://hl7.org/fhir/StructureDefinition/RelatedPerson', + 'Encounter' => 'http://hl7.org/fhir/us/davinci-crd/StructureDefinition/profile-encounter', + 'DeviceRequest' => 'http://hl7.org/fhir/us/davinci-crd/StructureDefinition/profile-devicerequest', + 'MedicationRequest' => 'http://hl7.org/fhir/us/davinci-crd/StructureDefinition/profile-medicationrequest', + 'NutritionOrder' => 'http://hl7.org/fhir/us/davinci-crd/StructureDefinition/profile-nutritionorder', + 'ServiceRequest' => 'http://hl7.org/fhir/us/davinci-crd/StructureDefinition/profile-servicerequest', + 'VisionPrescription' => 'http://hl7.org/fhir/us/davinci-crd/StructureDefinition/profile-visionprescription' + } + end + + def validate_prefetch_coverage(received_resource, advertised_prefetch_key, + received_context_patient_id, advertised_status) + assert_resource_type('Bundle', resource: received_resource) + assert(received_resource.entry.any?, 'Bundle of coverage resources received from prefetch is empty') + coverage_resource = received_resource.entry.first.resource + assert_resource_type('Coverage', resource: coverage_resource) + assert_valid_resource(resource: coverage_resource, + profile_url: structure_definition_map['Coverage']) + + coverage_beneficiary_reference = coverage_resource.beneficiary + coverage_beneficiary_patient_id = coverage_beneficiary_reference.reference_id + assert(coverage_beneficiary_patient_id.present?, + "Could not get beneficiary reference id from `#{advertised_prefetch_key}` field's Coverage resource") + + assert(coverage_beneficiary_patient_id == received_context_patient_id, + %(Expected `#{advertised_prefetch_key}` field's Coverage resource to have a `beneficiary` reference id of + '#{received_context_patient_id}', instead was '#{coverage_beneficiary_patient_id}')) + + coverage_status = coverage_resource.status + assert(coverage_status == advertised_status, + %(Expected `#{advertised_prefetch_key}` field's Coverage resource to have a `status` of + '#{advertised_status}', instead was '#{coverage_status}')) + end + + def validate_prefetch_resource(received_resource, advertised_prefetch_key, context_field_resource_type, + context_field_id) + assert_resource_type(context_field_resource_type, resource: received_resource) + + if hook_name == 'order-dispatch' + assert_valid_resource(resource: received_resource) + else + assert_valid_resource(resource: received_resource, + profile_url: structure_definition_map[context_field_resource_type]) + end + + received_prefetch_resource_id = received_resource.id + assert(received_prefetch_resource_id.present?, + "`#{advertised_prefetch_key}` field's FHIR resource does not contain the `id` field") + assert(received_prefetch_resource_id == context_field_id, + %(Expected `#{advertised_prefetch_key}` field's FHIR resource to have an `id` of '#{context_field_id}', + instead was '#{received_prefetch_resource_id}')) + end + + run do + assert_valid_json(request.request_body) + request_body = JSON.parse(request.request_body) + + received_prefetch = request_body['prefetch'] + received_context = request_body['context'] + + skip_if received_prefetch.blank?, 'Received hook request does not contain the `prefetch` field.' + skip_if received_context.blank?, + %(Received hook request does not contain the `context` field which is needed to validate the `prefetch` + field) + + advertised_hook_service = cds_services_json.find { |service| service['hook'] == hook_name } + + advertised_prefetch_fields = advertised_hook_service['prefetch'] + + advertised_prefetch_fields.each do |advertised_prefetch_key, advertised_prefetch_template| + next unless received_prefetch[advertised_prefetch_key].present? + + assert(received_prefetch[advertised_prefetch_key].is_a?(Hash), + "Prefetch field `#{advertised_prefetch_key}` is not of type `Hash`.") + + received_prefetch_resource = FHIR.from_contents(received_prefetch[advertised_prefetch_key].to_json) + + if advertised_prefetch_template.include?('?') + advertised_prefetch_fhir_search = advertised_prefetch_template.gsub(/{|}/, '').split('?') + advertised_prefetch_resource_type = advertised_prefetch_fhir_search.first + + if advertised_prefetch_resource_type == 'Coverage' + advertised_coverage_query_params = Rack::Utils.parse_nested_query(advertised_prefetch_fhir_search.last) + + advertised_patient_token = advertised_coverage_query_params['patient'] + advertised_context_patient_id_key = advertised_patient_token.split('.').last + received_context_patient_id = received_context[advertised_context_patient_id_key] + + advertised_status_param = advertised_coverage_query_params['status'] + + validate_prefetch_coverage(received_prefetch_resource, advertised_prefetch_key, received_context_patient_id, + advertised_status_param) + end + else + advertised_prefetch_token = advertised_prefetch_template.gsub(/{|}/, '').split('/') + advertised_context_id = advertised_prefetch_token.last.split('.').last + + if advertised_prefetch_token.length == 1 + received_context_reference = FHIR::Reference.new(reference: received_context[advertised_context_id]) + received_context_resource_type = received_context_reference.resource_type + received_context_id = received_context_reference.reference_id + else + received_context_id = received_context[advertised_context_id] + received_context_resource_type = advertised_prefetch_token.first + end + validate_prefetch_resource(received_prefetch_resource, advertised_prefetch_key, + received_context_resource_type, received_context_id) + end + end + end + end +end diff --git a/lib/davinci_crd_test_kit/client_tests/order_dispatch_receive_request_test.rb b/lib/davinci_crd_test_kit/client_tests/order_dispatch_receive_request_test.rb new file mode 100644 index 0000000..6796c61 --- /dev/null +++ b/lib/davinci_crd_test_kit/client_tests/order_dispatch_receive_request_test.rb @@ -0,0 +1,79 @@ +require_relative '../urls' + +module DaVinciCRDTestKit + class OrderDispatchReceiveRequestTest < Inferno::Test + include URLs + + id :crd_order_dispatch_request + title 'Request received for order-dispatch hook' + description %( + This test waits for an incoming [order-dispatch](https://hl7.org/fhir/us/davinci-crd/STU2/hooks.html#order-dispatch) + hook request and responds to the client with the response types selected as an input. This hook is a 'primary' + hook, meaning that CRD Servers SHALL, at minimum, return a [Coverage Information](https://hl7.org/fhir/us/davinci-crd/STU2/StructureDefinition-ext-coverage-information.html) + system action for these hooks, even if the response indicates that further information is needed or that the + level of detail provided is insufficient to determine coverage. + ) + receives_request :order_dispatch + + input :iss + input :order_dispatch_selected_response_types, + title: 'Response types to return from order-dispatch hook requests', + description: %( + Select the cards/action response types that the Inferno hook request endpoints will return. The default + response type that will be returned for this hook is the `Coverage Information` card type. + ), + type: 'checkbox', + default: ['coverage_information'], + options: { + list_options: [ + { + label: 'External Reference', + value: 'external_reference' + }, + { + label: 'Instructions', + value: 'instructions' + }, + { + label: 'Coverage Information', + value: 'coverage_information' + }, + { + label: 'Request Form Completion', + value: 'request_form_completion' + }, + { + label: 'Create/Update Coverage Information', + value: 'create_update_coverage_info' + }, + { + label: 'Launch SMART Application', + value: 'launch_smart_app' + }, + { + label: 'Propose Alternate Request', + value: 'propose_alternate_request' + }, + { + label: 'Additional Orders as Companions/Prerequisites', + value: 'companions_prerequisites' + } + ] + } + + run do + wait( + identifier: "order-dispatch #{iss}", + message: %( + **Order Dispatch CDS Service Test**: + + Invoke the order-dispatch hook and send a request to: + + `#{order_dispatch_url}` + + Inferno will process the request and return CDS cards if successful. + ) + ) + end + end +end diff --git a/lib/davinci_crd_test_kit/client_tests/order_select_receive_request_test.rb b/lib/davinci_crd_test_kit/client_tests/order_select_receive_request_test.rb new file mode 100644 index 0000000..b86959c --- /dev/null +++ b/lib/davinci_crd_test_kit/client_tests/order_select_receive_request_test.rb @@ -0,0 +1,76 @@ +require_relative '../urls' + +module DaVinciCRDTestKit + class OrderSelectReceiveRequestTest < Inferno::Test + include URLs + + id :crd_order_select_request + title 'Request received for order-select hook' + description %( + This test waits for an incoming [order-select](https://hl7.org/fhir/us/davinci-crd/STU2/hooks.html#order-select) + hook request and responds to the client with the response types selected as an input. + ) + receives_request :order_select + + input :iss + input :order_select_selected_response_types, + title: 'Response types to return from order-select hook requests', + description: %( + Select the cards/action response types that the Inferno hook request endpoints will return. The default + response type that will be returned for this hook is the `Instructions` card type. + ), + type: 'checkbox', + default: ['instructions'], + options: { + list_options: [ + { + label: 'External Reference', + value: 'external_reference' + }, + { + label: 'Instructions', + value: 'instructions' + }, + { + label: 'Coverage Information', + value: 'coverage_information' + }, + { + label: 'Request Form Completion', + value: 'request_form_completion' + }, + { + label: 'Create/Update Coverage Information', + value: 'create_update_coverage_info' + }, + { + label: 'Launch SMART Application', + value: 'launch_smart_app' + }, + { + label: 'Propose Alternate Request', + value: 'propose_alternate_request' + }, + { + label: 'Additional Orders as Companions/Prerequisites', + value: 'companions_prerequisites' + } + ] + } + + run do + wait( + identifier: "order-select #{iss}", + message: %( + **Order Select CDS Service Test**: + + Invoke the order-select hook and send a request to: + + `#{order_select_url}` + + Inferno will process the request and return CDS cards if successful. + ) + ) + end + end +end diff --git a/lib/davinci_crd_test_kit/client_tests/order_sign_receive_request_test.rb b/lib/davinci_crd_test_kit/client_tests/order_sign_receive_request_test.rb new file mode 100644 index 0000000..3b9bfd8 --- /dev/null +++ b/lib/davinci_crd_test_kit/client_tests/order_sign_receive_request_test.rb @@ -0,0 +1,79 @@ +require_relative '../urls' + +module DaVinciCRDTestKit + class OrderSignReceiveRequestTest < Inferno::Test + include URLs + + id :crd_order_sign_request + title 'Request received for order-sign hook' + description %( + This test waits for an incoming [order-sign](https://hl7.org/fhir/us/davinci-crd/STU2/hooks.html#order-sign) + hook request and responds to the client with the response types selected as an input. This hook is a 'primary' + hook, meaning that CRD Servers SHALL, at minimum, return a [Coverage Information](https://hl7.org/fhir/us/davinci-crd/STU2/StructureDefinition-ext-coverage-information.html) + system action for these hooks, even if the response indicates that further information is needed or that the + level of detail provided is insufficient to determine coverage. + ) + receives_request :order_sign + + input :iss + input :order_sign_selected_response_types, + title: 'Response types to return from order-sign hook requests', + description: %( + Select the cards/action response types that the Inferno hook request endpoints will return. The default + response type that will be returned for this hook is the `Coverage Information` card type. + ), + type: 'checkbox', + default: ['coverage_information'], + options: { + list_options: [ + { + label: 'External Reference', + value: 'external_reference' + }, + { + label: 'Instructions', + value: 'instructions' + }, + { + label: 'Coverage Information', + value: 'coverage_information' + }, + { + label: 'Request Form Completion', + value: 'request_form_completion' + }, + { + label: 'Create/Update Coverage Information', + value: 'create_update_coverage_info' + }, + { + label: 'Launch SMART Application', + value: 'launch_smart_app' + }, + { + label: 'Propose Alternate Request', + value: 'propose_alternate_request' + }, + { + label: 'Additional Orders as Companions/Prerequisites', + value: 'companions_prerequisites' + } + ] + } + + run do + wait( + identifier: "order-sign #{iss}", + message: %( + **Order Sign CDS Service Test**: + + Invoke the order-sign hook and send a request to: + + `#{order_sign_url}` + + Inferno will process the request and return CDS cards if successful. + ) + ) + end + end +end diff --git a/lib/davinci_crd_test_kit/client_tests/retrieve_jwks_test.rb b/lib/davinci_crd_test_kit/client_tests/retrieve_jwks_test.rb new file mode 100644 index 0000000..7ca0230 --- /dev/null +++ b/lib/davinci_crd_test_kit/client_tests/retrieve_jwks_test.rb @@ -0,0 +1,65 @@ +module DaVinciCRDTestKit + class RetrieveJWKSTest < Inferno::Test + id :crd_retrieve_jwks + title 'JWKS can be retrieved' + description %( + Verify that the JWKS can be retrieved from the JWKS uri if it is present in the `jku` field within the JWT token + header. As per the [CDS hooks specification](https://cds-hooks.hl7.org/2.0#trusting-cds-clients), if the jku + header field is ommitted, the CDS Client and CDS Service SHALL communicate the JWK Set out-of-band. Therefore, + if the client does not make their keys publicly available via a uri in the `jku` field, the user must + submit the jwk_set as an input to the test. + ) + + input :auth_token_header_json + input :jwk_set, + title: "The Client's JWK Set containing it's public key", + description: %( + Must supply if you do not make your keys publicly available via a uri in the authorization JWT header `jku` + field' + ), + type: 'textarea', + optional: true + output :crd_jwks_json, :crd_jwks_keys_json + makes_request :crd_client_jwks + + run do + token_header = JSON.parse(auth_token_header_json) + jku = token_header['jku'] + + if jku.present? + get(jku, name: :crd_client_jwks) + + assert_response_status(200) + assert_valid_json(response[:body]) + output crd_jwks_json: response[:body] + + jwks = JSON.parse(response[:body]) + else + skip_if jwk_set.blank?, + %(JWK Set must be inputted if Client's JWK Set is not available via a URL identified by the jku header + field) + + jwks = JSON.parse(jwk_set) + end + + keys = jwks['keys'] + assert keys.is_a?(Array), 'JWKS `keys` field must be an array' + + assert keys.present?, 'The JWK set returned contains no public keys' + + keys.each do |jwk| + JWT::JWK.import(jwk.deep_symbolize_keys) + rescue StandardError + assert false, "Invalid JWK: #{jwk.to_json}" + end + + kid_presence = keys.all? { |key| key['kid'].present? } + assert kid_presence, '`kid` field must be present in each key if JWKS contains multiple keys' + + kid_uniqueness = keys.map { |key| key['kid'] }.uniq.length == keys.length + assert kid_uniqueness, '`kid` must be unique within the client\' JWK Set.' + + output crd_jwks_keys_json: keys.to_json + end + end +end diff --git a/lib/davinci_crd_test_kit/client_tests/token_header_test.rb b/lib/davinci_crd_test_kit/client_tests/token_header_test.rb new file mode 100644 index 0000000..d88aad0 --- /dev/null +++ b/lib/davinci_crd_test_kit/client_tests/token_header_test.rb @@ -0,0 +1,34 @@ +module DaVinciCRDTestKit + class TokenHeaderTest < Inferno::Test + id :crd_token_header + title 'Authorization token header contains required information' + description %( + Verify that the JWT header contains the header fields required by the [CDS hooks spec](https://cds-hooks.hl7.org/2.0#trusting-cds-clients). + The `alg`, `kid`, and `typ` fields are required. This test also verifies that the `typ` field is set to `JWT` and + that the key used to sign the token can be identified in the JWKS. + ) + + input :auth_token_header_json, :crd_jwks_keys_json + output :auth_token_jwk_json + + run do + header = JSON.parse(auth_token_header_json) + + algorithm = header['alg'] + assert algorithm.present?, 'Token header must have the `alg` field' + assert algorithm != 'none', 'Token header `alg` field cannot be set to none' + + assert header['typ'].present?, 'Token header must have the `typ` field' + assert header['typ'] == 'JWT', "Token header `typ` field must be set to 'JWT', instead was #{header['typ']}" + + assert header['kid'].present?, 'Token header must have the `kid` field' + kid = header['kid'] + keys = JSON.parse(crd_jwks_keys_json) + + jwk = keys.find { |key| key['kid'] == kid } + assert jwk.present?, "JWKS did not contain a public key with an id of `#{kid}`" + + output auth_token_jwk_json: jwk.to_json + end + end +end diff --git a/lib/davinci_crd_test_kit/client_tests/token_payload_test.rb b/lib/davinci_crd_test_kit/client_tests/token_payload_test.rb new file mode 100644 index 0000000..650d828 --- /dev/null +++ b/lib/davinci_crd_test_kit/client_tests/token_payload_test.rb @@ -0,0 +1,61 @@ +module DaVinciCRDTestKit + class TokenPayloadTest < Inferno::Test + include URLs + id :crd_token_payload + title 'Authorization token payload has required claims and a valid signature' + description %( + Verify that the JWT payload contains the payload fields required by the + [CDS hooks spec](https://cds-hooks.hl7.org/2.0#trusting-cds-clients). + The `iss`, `aud`, `exp`, `iat`, and `jti` claims are required. + Additionally: + + - `iss` must match the `issuer` from the `iss` input + - `aud` must match the URL of the CDS Service endpoint being invoked + - `exp` must represent a time in the future + - `jti` must be a non-blank string that uniquely identifies this authentication JWT + ) + + REQUIRED_CLAIMS = ['iss', 'aud', 'exp', 'iat', 'jti'].freeze + + def required_claims + REQUIRED_CLAIMS.dup + end + + def hook_url + base_url + config.options[:hook_path] + end + + input :auth_token, + :auth_token_jwk_json, + :iss + + run do + begin + jwk = JSON.parse(auth_token_jwk_json).deep_symbolize_keys + + payload, = + JWT.decode( + auth_token, + JWT::JWK.import(jwk).public_key, + true, + algorithms: [jwk[:alg]], + exp_leeway: 60, + iss:, + aud: hook_url, + verify_not_before: false, + verify_iat: false, + verify_jti: true, + verify_iss: true, + verify_aud: true + ) + rescue StandardError => e + assert false, "Token validation error: #{e.message}" + end + + missing_claims = required_claims - payload.keys + missing_claims_string = missing_claims.map { |claim| "`#{claim}`" }.join(', ') + + assert missing_claims.empty?, "JWT payload missing required claims: #{missing_claims_string}" + end + end +end diff --git a/lib/davinci_crd_test_kit/crd_client_suite.rb b/lib/davinci_crd_test_kit/crd_client_suite.rb new file mode 100644 index 0000000..6f4cc88 --- /dev/null +++ b/lib/davinci_crd_test_kit/crd_client_suite.rb @@ -0,0 +1,156 @@ +require_relative 'client_fhir_api_group' +require_relative 'client_hooks_group' +require_relative 'routes/cds_services_discovery_handler' +require_relative 'tags' +require_relative 'urls' +require_relative 'crd_options' +require_relative 'routes/hook_request_endpoint' +require_relative 'ext/inferno_core/runnable' +require_relative 'version' + +module DaVinciCRDTestKit + class CRDClientSuite < Inferno::TestSuite + id :crd_client + title 'Da Vinci CRD Client Test Suite' + description <<~DESCRIPTION + The Da Vinci CRD Client Test Suite tests the conformance of client systems + to [version 2.0.1 of the Da Vinci Coverage Requirements Discovery (CRD) + Implementation Guide](https://hl7.org/fhir/us/davinci-crd/STU2). + + ## Overview + This suite contains two groups of tests. The Hooks group receives and + responds to incoming CDS Hooks requests from CRD clients. The FHIR API + group makes FHIR requests to CRD Clients to verify that they support the + FHIR interactions defined in the implementation guide. + + ## CDS Services + This suite provides basic CDS services for [the six hooks contained in the + implementation + guide](https://hl7.org/fhir/us/davinci-crd/STU2/hooks.html). The discovery + endpoint is located at: + + * `#{Inferno::Application['base_url']}/custom/#{id}/cds-services` + + ## SMART App Launch + Use this information when registering Inferno as a SMART App: + + * Launch URI: `#{SMARTAppLaunch::AppLaunchTest.config.options[:launch_uri]}` + * Redirect URI: `#{SMARTAppLaunch::AppRedirectTest.config.options[:redirect_uri]}` + + If a client receives a SMART App Launch card in a response and would like + to test their ability to launch Inferno as a SMART App, first run the + SMART on FHIR Discovery and SMART EHR Launch groups under FHIR API > + Authorization. When running the SMART EHR Launch group, Inferno will wait + for the incoming SMART App Launch request, and this is the time to perform + the launch from the client being tested. + + ## Running the Tests + If you would like to try out the tests against [the public CRD reference + client](https://crd-request-generator.davinci.hl7.org/), you can do so by: + 1. Selecting the *CRD Request Generator RI* option from the Preset + dropdown in the upper left. + 2. Selecting the *order-sign* hook group on the left menu. + 3. Clicking on the *RUN TESTS* button in the upper right. + 4. Clicking the *Submit* button at the bottom of the input dialog. + 5. Follow the instructions in the wait dialog. + 6. Open the reference client in another tab/browser. + 7. Update the *CRD Server* field in the client configuration to point to + the discovery endpoint of this suite provided above, and the *Order + Sign Rest End Point* + to the service id provided in the wait dialog. + 8. Select the patient data to be used to form the request, then submit the + request. + + You can run these tests using your own client by updating the inputs with + your own data. + + Note that: + - You can only sequentially *RUN ALL TESTS* if your system supports all + hooks. + - Systems are not expected to pass the *FHIR RESTful Capabilities* tests + based on the provided inputs, as the resource might not exist on the + client's FHIR server. + + ## Limitations + The test suite does not implement any sort of payer business logic, so the + responses to hook calls are simple hard-coded responses. Hook + configuration is not tested. + DESCRIPTION + + suite_summary <<~SUMMARY + The Da Vinci CRD Client Test Suite tests the conformance of client systems + to [version 2.0.1 of the Da Vinci Coverage Requirements Discovery (CRD) + Implementation Guide](https://hl7.org/fhir/us/davinci-crd/STU2). + SUMMARY + + version VERSION + + links [ + { + label: 'Report Issue', + url: 'https://github.com/inferno-framework/davinci-crd-test-kit/issues' + }, + { + label: 'Open Source', + url: 'https://github.com/inferno-framework/davinci-crd-test-kit' + }, + { + label: 'Download', + url: 'https://github.com/inferno-framework/davinci-crd-test-kit/releases' + } + ] + + fhir_resource_validator do + igs('hl7.fhir.us.davinci-crd', 'hl7.fhir.us.core') + + exclude_message do |message| + message.message.match?(/\A\S+: \S+: URL value '.*' does not resolve/) + end + end + + suite_option :smart_app_launch_version, + title: 'SMART App Launch Version', + list_options: [ + { + label: 'SMART App Launch 1.0.0', + value: CRDOptions::SMART_1 + }, + { + label: 'SMART App Launch 2.0.0', + value: CRDOptions::SMART_2 + } + ] + + def self.test_resumes?(test) + !test.config.options[:accepts_multiple_requests] + end + + def self.extract_token_from_query_params(request) + request.query_parameters['token'] + end + + route :get, '/cds-services', Routes::CDSServicesDiscoveryHandler + # TODO + # route :post, '/cds-services/:cds-service_id', cds_service_handler + + allow_cors APPOINTMENT_BOOK_PATH, ENCOUNTER_START_PATH, ENCOUNTER_DISCHARGE_PATH, ORDER_DISPATCH_PATH, + ORDER_SELECT_PATH, ORDER_SIGN_PATH + suite_endpoint :post, APPOINTMENT_BOOK_PATH, HookRequestEndpoint + suite_endpoint :post, ENCOUNTER_START_PATH, HookRequestEndpoint + suite_endpoint :post, ENCOUNTER_DISCHARGE_PATH, HookRequestEndpoint + suite_endpoint :post, ORDER_DISPATCH_PATH, HookRequestEndpoint + suite_endpoint :post, ORDER_SELECT_PATH, HookRequestEndpoint + suite_endpoint :post, ORDER_SIGN_PATH, HookRequestEndpoint + + resume_test_route :get, RESUME_PASS_PATH do |request| + CRDClientSuite.extract_token_from_query_params(request) + end + resume_test_route :get, RESUME_FAIL_PATH, result: 'fail' do |request| + CRDClientSuite.extract_token_from_query_params(request) + end + + group from: :crd_client_hooks + + group from: :crd_client_fhir_api + end +end diff --git a/lib/davinci_crd_test_kit/crd_jwks.json b/lib/davinci_crd_test_kit/crd_jwks.json new file mode 100644 index 0000000..eb46118 --- /dev/null +++ b/lib/davinci_crd_test_kit/crd_jwks.json @@ -0,0 +1,59 @@ +{ + "keys": [ + { + "kty": "EC", + "crv": "P-384", + "x": "JQKTsV6PT5Szf4QtDA1qrs0EJ1pbimQmM2SKvzOlIAqlph3h1OHmZ2i7MXahIF2C", + "y": "bRWWQRJBgDa6CTgwofYrHjVGcO-A7WNEnu4oJA5OUJPPPpczgx1g2NsfinK-D2Rw", + "use": "sig", + "key_ops": [ + "verify" + ], + "ext": true, + "kid": "4b49a739d1eb115b3225f4cf9beb6d1b", + "alg": "ES384" + }, + { + "kty": "EC", + "crv": "P-384", + "d": "kDkn55p7gryKk2tj6z2ij7ExUnhi0ngxXosvqa73y7epwgthFqaJwApmiXXU2yhK", + "x": "JQKTsV6PT5Szf4QtDA1qrs0EJ1pbimQmM2SKvzOlIAqlph3h1OHmZ2i7MXahIF2C", + "y": "bRWWQRJBgDa6CTgwofYrHjVGcO-A7WNEnu4oJA5OUJPPPpczgx1g2NsfinK-D2Rw", + "key_ops": [ + "sign" + ], + "ext": true, + "kid": "4b49a739d1eb115b3225f4cf9beb6d1b", + "alg": "ES384" + }, + { + "kty": "RSA", + "alg": "RS384", + "n": "vjbIzTqiY8K8zApeNng5ekNNIxJfXAue9BjoMrZ9Qy9m7yIA-tf6muEupEXWhq70tC7vIGLqJJ4O8m7yiH8H2qklX2mCAMg3xG3nbykY2X7JXtW9P8VIdG0sAMt5aZQnUGCgSS3n0qaooGn2LUlTGIR88Qi-4Nrao9_3Ki3UCiICeCiAE224jGCg0OlQU6qj2gEB3o-DWJFlG_dz1y-Mxo5ivaeM0vWuodjDrp-aiabJcSF_dx26sdC9dZdBKXFDq0t19I9S9AyGpGDJwzGRtWHY6LsskNHLvo8Zb5AsJ9eRZKpnh30SYBZI9WHtzU85M9WQqdScR69Vyp-6Uhfbvw", + "e": "AQAB", + "use": "sig", + "key_ops": [ + "verify" + ], + "ext": true, + "kid": "b41528b6f37a9500edb8a905a595bdd7" + }, + { + "kty": "RSA", + "alg": "RS384", + "n": "vjbIzTqiY8K8zApeNng5ekNNIxJfXAue9BjoMrZ9Qy9m7yIA-tf6muEupEXWhq70tC7vIGLqJJ4O8m7yiH8H2qklX2mCAMg3xG3nbykY2X7JXtW9P8VIdG0sAMt5aZQnUGCgSS3n0qaooGn2LUlTGIR88Qi-4Nrao9_3Ki3UCiICeCiAE224jGCg0OlQU6qj2gEB3o-DWJFlG_dz1y-Mxo5ivaeM0vWuodjDrp-aiabJcSF_dx26sdC9dZdBKXFDq0t19I9S9AyGpGDJwzGRtWHY6LsskNHLvo8Zb5AsJ9eRZKpnh30SYBZI9WHtzU85M9WQqdScR69Vyp-6Uhfbvw", + "e": "AQAB", + "d": "rriV9GYimi5by7TOW4xNh6_gYBHVRDBsft2OFF8qapdVHt2GNuRDDxc_B6ga6TY2Enh2MLKLTr1dD3W4FIdTCJiMerrorp07FJS7nJEMgWQDxrfgkX4_EqrhW42L5d4vypYnRXEEW6u4gzkx5uFOkdvJBIK7CsIdSaBFYhochnynNgvbKWasi4rl2hayEH8tdf3B7Z6OIH9alspBTaq3j_zJt_KkrpYEzIUb4UgALB5NTWn5YKr0Avk_asOg8YfjViQwO9ASGaWjQeJ2Rx8OEQwBMQHSDMCSWNiWmYOu9PcwSZFc1vLxqzyIM8QrQSJHCCMo_wGYgke_r0CLeONHEQ", + "p": "5hH_QApWGeobRi1n7XbMfJYohB8K3JDPa0MspfplHpJ-17JiGG2sNoBdBcpaPRf9OX48P8VqO0qrSSRAk-I-uO6OO9BHbIukXJILqnY2JmurYzbcYbt5FVbknlHRJojkF6-7sFBazpueUlOnXCw7X7Z_SkfNE4QX5Ejm2Zm5mek", + "q": "06bZz7c7K9s1-aEZsxYnLJ9eTpKlt1tIBDA_LwIh5W3w259pes2kUtimbnkyOf-V2ZIERsFCh5s-S9IOEMvAIa6M5j9GW1ILNT7AcHIUfcyFcH-FF8BU_KJdRP5PXnIXFdYcylvsdoIdchy1AaUIzyiKRCU3HBYI75hez0l_F2c", + "dp": "h_sVIXW6hCCRND48EedIX06k7conMkxIu_39ErDXOWWeoMAnKIcR5TijQnviL__QxD1vQMXezuKIMHfDz2RGbClbWdD1lhtG7wvG515tDPJQXxia0wzqOQmdoFF9S8hXAAT26vPjaAAkaEZXQaxG_4Au5elgNWu6b0wDXZN1Vpk", + "dq": "GqS0YpuUTU8JGmWXUJ4HTGy7eHSpe8134V8ZdRd1oOYYHe2RX64nc25mdR24nuh3uq3Q7_9AGsYGL5E_yAl-JD9O6WUpvDE1y_wcSYty3Os0GRdUb8r8Z9kgmKDS6Pa_xTXw5eBwgfKbNlQ6zPwzgbB-x1lP-K8lbNPni3ybDR0", + "qi": "cqQfoi0sM5Su8ZOhznmdWrDIQB28H6fBKiabgaIKkbWZV4e0nwFvLquHjPOvv4Ao8iEGU5dyhvg0n5BKYPi-4mp6M6OA1sy0NrTr7EsKSYGyu2pBq9rw4oAYTM2LXKg6K-awgUUlkc451IwxHBAe15PWCBM3kvLQeijNid0Vz5I", + "key_ops": [ + "sign" + ], + "ext": true, + "kid": "b41528b6f37a9500edb8a905a595bdd7" + } + ] +} diff --git a/lib/davinci_crd_test_kit/crd_options.rb b/lib/davinci_crd_test_kit/crd_options.rb new file mode 100644 index 0000000..6c59ec0 --- /dev/null +++ b/lib/davinci_crd_test_kit/crd_options.rb @@ -0,0 +1,9 @@ +module DaVinciCRDTestKit + module CRDOptions + SMART_1 = 'smart_app_launch_1'.freeze + SMART_2 = 'smart_app_launch_2'.freeze + + SMART_1_REQUIREMENT = { smart_app_launch_version: SMART_1 }.freeze + SMART_2_REQUIREMENT = { smart_app_launch_version: SMART_2 }.freeze + end +end diff --git a/lib/davinci_crd_test_kit/crd_server_suite.rb b/lib/davinci_crd_test_kit/crd_server_suite.rb new file mode 100644 index 0000000..b050b42 --- /dev/null +++ b/lib/davinci_crd_test_kit/crd_server_suite.rb @@ -0,0 +1,115 @@ +require_relative 'jwt_helper' +require_relative 'routes/jwk_set_endpoint_handler' +require_relative 'server_discovery_group' +require_relative 'server_hooks_group' +require_relative 'version' + +module DaVinciCRDTestKit + class CRDServerSuite < Inferno::TestSuite + id :crd_server + title 'Da Vinci CRD Server Test Suite' + description <<~DESCRIPTION + The Da Vinci CRD Server Test Suite tests the conformance of server systems + to [version 2.0.1 of the Da Vinci Coverage Requirements Discovery (CRD) + Implementation Guide](https://hl7.org/fhir/us/davinci-crd/STU2). + + ## Overview + This suite contains two groups of tests. The Discovery group validates a + CRD server's discovery response. The Hooks group makes CDS Hooks calls to + the server and validates the responses. By default, the hook requests are + based on examples from the CDS Hooks specification, but testers can + replace these with request bodies to elicit a particular response from + their system. + + ## Trusting CDS Clients + As specified in the [CDS Hooks Spec](https://cds-hooks.hl7.org/2.0/#trusting-cds-clients), + Each time a CDS Client transmits a request to a CDS Service which + requires authentication, the request MUST include an Authorization + header presenting the JWT as a “Bearer” token: + `Authorization: Bearer {{JWT}}` + + Inferno self-issues the JWT for each CDS Service call. The following + info is needed to register Inferno: + + - **ISS**: `#{Inferno::Application[:base_url]}/custom/crd_server` + - **JWK Set Url**: + `#{Inferno::Application[:base_url]}/custom/crd_server/jwks.json` + + ## Running the Tests + Execution of these tests require a significant amount of tester input in + the form of requests that Inferno will make against the server under test. + + If you would like to try out the tests using examples from the IG and the + [CDS Hooks spec](https://cds-hooks.hl7.org/2.0/) against [the public CRD + reference server endpoint](https://crd.davinci.hl7.org/), you can do so + by: + 1. Selecting the *CRD Server RI* option from the + Preset dropdown in the upper left + 2. Clicking the *Run All Tests* button in the upper right + 3. Clicking the *Submit* button at the bottom of the input dialog + + You can run these tests using your own server by updating the "CRD server + base URL" and, if needed, providing requests inputs you wish to use for + each hook your server supports. + + Note that the provided inputs for these tests are not complete and systems + are not expected to pass the tests based on them. + + + ## Limitations + Inferno is unable to determine what requests will result in specific kinds + of responses from the server under test (e.g., what will result in + Instructions being returned vs. Coverage Information). As a result, the + tester must supply the request bodies which will cause the system under + test to return the desired response types. + + The ability of a CRD Server to request additional FHIR resources is not + tested. Hook configuration is not tested. + DESCRIPTION + + suite_summary <<~SUMMARY + The Da Vinci CRD Server Test Suite tests the conformance of server systems + to [version 2.0.1 of the Da Vinci Coverage Requirements Discovery (CRD) + Implementation Guide](https://hl7.org/fhir/us/davinci-crd/STU2). + SUMMARY + + version VERSION + + links [ + { + label: 'Report Issue', + url: 'https://github.com/inferno-framework/davinci-crd-test-kit/issues' + }, + { + label: 'Open Source', + url: 'https://github.com/inferno-framework/davinci-crd-test-kit' + }, + { + label: 'Download', + url: 'https://github.com/inferno-framework/davinci-crd-test-kit/releases' + } + ] + + input :base_url, + title: 'CRD server base URL' + + fhir_resource_validator do + igs('hl7.fhir.us.davinci-crd', 'hl7.fhir.us.core') + + exclude_message do |message| + message.message.match?(/\A\S+: \S+: URL value '.*' does not resolve/) + end + end + + def inferno_base_url + suite_id = self.class.suite.id + @inferno_base_url ||= "#{Inferno::Application['base_url']}/custom/#{suite_id}" + end + + route :get, '/jwks.json', Routes::JWKSetEndpointHandler + + group from: :crd_server_discovery_group + + group from: :crd_server_hooks + end +end diff --git a/lib/davinci_crd_test_kit/ext/inferno_core/runnable.rb b/lib/davinci_crd_test_kit/ext/inferno_core/runnable.rb new file mode 100644 index 0000000..8dd8973 --- /dev/null +++ b/lib/davinci_crd_test_kit/ext/inferno_core/runnable.rb @@ -0,0 +1,22 @@ +module Inferno + module DSL + module Runnable + PRE_FLIGHT_HANDLER = proc do + [ + 200, + { + 'Access-Control-Allow-Origin' => '*', + 'Access-Control-Allow-Headers' => 'Content-Type, Authorization' + }, + [''] + ] + end + + def allow_cors(*paths) + paths.each do |path| + route(:options, path, PRE_FLIGHT_HANDLER) + end + end + end + end +end diff --git a/lib/davinci_crd_test_kit/hook_request_field_validation.rb b/lib/davinci_crd_test_kit/hook_request_field_validation.rb new file mode 100644 index 0000000..bd8336a --- /dev/null +++ b/lib/davinci_crd_test_kit/hook_request_field_validation.rb @@ -0,0 +1,410 @@ +module DaVinciCRDTestKit + module HookRequestFieldValidation + def hook_required_fields + { + 'hook' => String, + 'hookInstance' => String, + 'context' => Hash + } + end + + def fhir_authorization_required_fields + { + 'access_token' => String, + 'token_type' => String, + 'expires_in' => Integer, + 'scope' => String, + 'subject' => String + } + end + + def hook_optional_fields + { + 'fhirServer' => String, + 'fhirAuthorization' => Hash, + 'prefetch' => Hash + } + end + + def common_context_fields + { 'userId' => String, 'patientId' => String }.freeze + end + + def context_required_fields_by_hook + { + 'appointment-book' => common_context_fields.merge('appointments' => Hash), + 'encounter-start' => common_context_fields.merge('encounterId' => String), + 'encounter-discharge' => common_context_fields.merge('encounterId' => String), + 'order-select' => common_context_fields.merge('selections' => Array, 'draftOrders' => Hash), + 'order-dispatch' => { 'patientId' => String, 'order' => String, 'performer' => String }, + 'order-sign' => common_context_fields.merge('draftOrders' => Hash) + }.freeze + end + + def context_optional_fields_by_hook + { + 'appointment-book' => { 'encounterId' => String }, + 'order-select' => { 'encounterId' => String }, + 'order-dispatch' => { 'task' => Hash }, + 'order-sign' => { 'encounterId' => String } + }.freeze + end + + def optional_field_resource_types + { + 'task' => 'Task' + } + end + + def context_user_types_by_hook + shared_resources = ['Practitioner', 'PractitionerRole'] + { + 'appointment-book' => ['Patient', 'RelatedPerson'].concat(shared_resources), + 'encounter-start' => shared_resources, + 'encounter-discharge' => shared_resources, + 'order-select' => shared_resources, + 'order-sign' => shared_resources + }.freeze + end + + def structure_definition_map + { + 'Practitioner' => 'http://hl7.org/fhir/us/davinci-crd/StructureDefinition/profile-practitioner', + 'PractitionerRole' => 'http://hl7.org/fhir/us/core/StructureDefinition/us-core-practitionerrole', + 'Patient' => 'http://hl7.org/fhir/us/davinci-crd/StructureDefinition/profile-patient', + 'Encounter' => 'http://hl7.org/fhir/us/davinci-crd/StructureDefinition/profile-encounter', + 'Appointment' => 'http://hl7.org/fhir/us/davinci-crd/StructureDefinition/profile-appointment', + 'DeviceRequest' => 'http://hl7.org/fhir/us/davinci-crd/StructureDefinition/profile-devicerequest', + 'MedicationRequest' => 'http://hl7.org/fhir/us/davinci-crd/StructureDefinition/profile-medicationrequest', + 'NutritionOrder' => 'http://hl7.org/fhir/us/davinci-crd/StructureDefinition/profile-nutritionorder', + 'ServiceRequest' => 'http://hl7.org/fhir/us/davinci-crd/StructureDefinition/profile-servicerequest', + 'VisionPrescription' => 'http://hl7.org/fhir/us/davinci-crd/StructureDefinition/profile-visionprescription', + 'Medication' => 'http://hl7.org/fhir/us/core/StructureDefinition/us-core-medication', + 'Device' => 'http://hl7.org/fhir/us/davinci-crd/StructureDefinition/profile-device', + 'CommunicationRequest' => 'http://hl7.org/fhir/us/davinci-crd/StructureDefinition/profile-communicationrequest', + 'Task' => 'http://hl7.org/fhir/us/davinci-crd/StructureDefinition/profile-taskquestionnaire' + }.freeze + end + + def hook_request_required_fields_check(request_body, hook_name) + hook_required_fields.each do |field, type| + assert(request_body[field], "Hook request did not contain required field: `#{field}`") + assert(request_body[field].is_a?(type), "Hook request field #{field} is not of type #{type}") + end + + assert(request_body['hook'] == hook_name, + "The `hook` field should be #{hook_name}, but was #{request_body['hook']}") + + return unless request_body['fhirAuthorization'] + + assert(request_body['fhirServer'], + 'Missing `fhirServer` field: If `fhirAuthorization` is provided, this field is REQUIRED.') + end + + def hook_request_fhir_auth_check(request_body) + if request_body['fhirAuthorization'] + + fhir_authorization = request_body['fhirAuthorization'] + + fhir_authorization_required_fields.each do |field, type| + assert(fhir_authorization[field], "`fhirAuthorization` did not contain required field: `#{field}`") + assert(fhir_authorization[field].is_a?(type), "`fhirAuthorization` field #{field} is not of type #{type}") + end + + assert(fhir_authorization['token_type'] == 'Bearer', + "`fhirAuthorization` `token_type` field is not set to 'Bearer'") + + access_token = fhir_authorization['access_token'] + + scopes = fhir_authorization['scope'].split + + if scopes.any? { |scope| scope.start_with?('patient/') } + info do + assert(fhir_authorization['patient'] && fhir_authorization['patient'].is_a?(String), + %(The `patient` field SHOULD be populated to identify the FHIR id of that patient when the granted + SMART scopes include patient scopes)) + end + end + end + { fhir_server_uri: request_body['fhirServer'], fhir_access_token: access_token } + end + + def hook_request_optional_fields_check(request_body) + hook_optional_fields.each do |field, type| + info do + assert(request_body[field], "Hook request did not contain optional field: `#{field}`") + end + if request_body[field] + assert(request_body[field].is_a?(type), "Hook request field #{field} is not of type #{type}") + end + end + hook_request_fhir_auth_check(request_body) + end + + def validate_presence_and_type(object, field_name, type, description = '') + value = object[field_name] + unless value + error_msg = "#{description} does not contain required field `#{field_name}`: #{description} `#{object}`." + add_message('error', error_msg) + return + end + + is_valid_type = type == 'URL' ? valid_url?(value) : value.is_a?(type) + unless is_valid_type + error_msg = type == 'URL' ? 'is not a valid URL' : "is not of type `#{type}`" + add_message('error', "#{description} field `#{field_name}` #{error_msg}: #{description} `#{object}`.") + return + end + + return unless value.blank? + + error_msg = "#{description} field `#{field_name}` should not be an empty #{type}: #{description} `#{object}`." + add_message('error', error_msg) + end + + def hook_request_context_check(context, hook_name) + required_fields = context_required_fields_by_hook[hook_name] + required_fields.each do |field, type| + validate_presence_and_type(context, field, type, "#{hook_name} request context") + end + context_validate_optional_fields(context, hook_name) + hook_specific_context_check(context, hook_name) + end + + def hook_specific_context_check(context, hook_name) + case hook_name + when 'appointment-book' + appointment_book_context_check(context) + when 'encounter-start', 'encounter-discharge' + encounter_start_or_discharge_context_check(context, hook_name) + when 'order-select', 'order-sign' + order_select_or_sign_context_check(context, hook_name) + when 'order-dispatch' + order_dispatch_context_check(context) + end + end + + def hook_user_type_check(context, hook_name) + supported_resource_types = context_user_types_by_hook[hook_name] + resource_reference_check(context['userId'], 'userId', supported_resource_types:) + end + + def resource_reference_check(reference, field_name, supported_resource_types: nil) + return unless reference.is_a?(String) && valid_reference_format?(reference, field_name) + + resource_type, resource_id = reference.split('/') + + if supported_resource_types && !supported_resource_types.include?(resource_type) + error_msg = "Unsupported resource type: `#{field_name}` type should be one " \ + "of the following: #{supported_resource_types.to_sentence}, but " \ + "received #{resource_type}." + + add_message('error', error_msg) + return + end + + query_and_validate_id_field(resource_type, resource_id) if client_test? && !field_name.include?('selections') + end + + def valid_reference_format?(reference, field_name) + resource_type, resource_id = reference.split('/') + return true if resource_type.present? && resource_id.present? + + add_message('error', "Invalid `#{field_name}` format. Expected `{resourceType}/{id}`, received `#{reference}`.") + false + end + + def id_only_fields_check(hook_name, context, id_fields) + id_fields.each do |field| + resource_id = context[field] + next unless resource_id.is_a?(String) && valid_id_format?(field, hook_name, resource_id) + + if client_test? + resource_type = field.split(/(?=[A-Z])/).first.capitalize + query_and_validate_id_field(resource_type, resource_id) + end + end + end + + def valid_id_format?(field, hook_name, resource_id) + if resource_id.include?('/') + error_msg = "`#{field}` in #{hook_name} context should be a plain ID, not a reference. Got: `#{resource_id}`." + add_message('error', error_msg) + false + end + true + end + + def bundle_entries_check(context, context_field_name, bundle, resource_types, status = nil) + target_resources = bundle.entry.map(&:resource).select { |r| resource_types.include?(r.resourceType) } + unless target_resources.present? + error_msg = "`#{context_field_name}` bundle must contain at least one of the expected resource types: " \ + "#{resource_types.to_sentence}. In Context `#{context}`" + add_message('error', error_msg) + return + end + + status_check(context, context_field_name, status, target_resources) + + target_resources.each do |resource| + resource_is_valid?(resource:, profile_url: structure_definition_map[resource.resourceType]) + end + end + + def status_check(context, context_field_name, status, resources) + return unless status && !resources.all? { |resource| resource.status == status } + + error_msg = "All #{resources.map(&:resourceType).uniq.to_sentence} resources in `#{context_field_name}` " \ + "bundle must have a `#{status}` status. In Context `#{context}`" + add_message('error', error_msg) + end + + def parse_fhir_bundle_from_context(context_field_name, context) + fhir_bundle = FHIR.from_contents(context[context_field_name].to_json) + unless fhir_bundle + error_msg = "`#{context_field_name}` field is not a FHIR resource: `#{context[context_field_name]}`. " \ + "In Context `#{context}`" + add_message('error', error_msg) + return + end + + return fhir_bundle if fhir_bundle.is_a?(FHIR::Bundle) + + error_msg = "Wrong context resource type: Expected `Bundle`, got `#{fhir_bundle.resourceType}`. " \ + "In Context `#{context}`" + add_message('error', error_msg) + nil + end + + def context_selections_check(context, selections, order_refs, expected_resource_types) + return unless selections.is_a?(Array) + + selections.each do |reference| + resource_reference_check(reference, 'selections item', supported_resource_types: expected_resource_types) + next if order_refs.include?(reference) + + error_msg = '`selections` field must reference FHIR resources in `draftOrders`. ' \ + "#{reference} is not in `draftOrders`. In Context `#{context}`" + add_message('error', error_msg) + end + end + + def appointment_book_context_check(context) + hook_user_type_check(context, 'appointment-book') + id_only_fields_check('appointment-book', context, ['patientId']) + + appointment_bundle = parse_fhir_bundle_from_context('appointments', context) + return unless appointment_bundle + + expected_resource_types = ['Appointment'] + bundle_entries_check(context, 'appointments', appointment_bundle, expected_resource_types, 'proposed') + end + + def encounter_start_or_discharge_context_check(context, hook_name) + hook_user_type_check(context, hook_name) + id_only_fields_check(hook_name, context, ['patientId', 'encounterId']) + end + + def order_select_or_sign_context_check(context, hook_name) + hook_user_type_check(context, hook_name) + id_only_fields_check(hook_name, context, ['patientId']) + + draft_orders_bundle = parse_fhir_bundle_from_context('draftOrders', context) + return unless draft_orders_bundle + + expected_resource_types = [ + 'DeviceRequest', 'MedicationRequest', 'NutritionOrder', + 'ServiceRequest', 'VisionPrescription' + ] + + bundle_entries_check(context, 'draftOrders', draft_orders_bundle, expected_resource_types) + + return unless hook_name == 'order-select' + + order_refs = draft_orders_bundle.entry.map(&:resource).map do |resource| + "#{resource.resourceType}/#{resource.id}" + end + context_selections_check(context, context['selections'], order_refs, expected_resource_types) + end + + def order_dispatch_context_check(context) + id_only_fields_check('order-dispatch', context, ['patientId']) + order_supported_resource_type = [ + 'DeviceRequest', 'MedicationRequest', 'NutritionOrder', + 'ServiceRequest', 'VisionPrescription' + ] + resource_reference_check(context['order'], 'order', supported_resource_types: order_supported_resource_type) + resource_reference_check(context['performer'], 'performer') + end + + def no_error_validation(message) + assert messages.none? { |msg| msg[:type] == 'error' }, message + end + + def valid_url?(url) + uri = URI.parse(url) + uri.host.present? && ['http', 'https'].include?(uri.scheme) + rescue URI::InvalidURIError + false + end + + def query_and_validate_id_field(resource_type, resource_id) + fhir_read(resource_type, resource_id) + status = request.response[:status] + unless status == 200 + add_message('error', "Unexpected response status: expected 200, but received #{status}") + return + end + unless resource.resourceType == resource_type + add_message('error', "Unexpected resource type: Expected `#{resource_type}`. Got `#{resource.resourceType}`.") + return + end + unless resource.id == resource_id + add_message('error', "Requested resource with id #{resource_id}, received resource with id #{resource.id}") + return + end + + profile_url = hook_name == 'order-dispatch' ? nil : structure_definition_map[resource_type] + resource_is_valid?(profile_url:) + end + + def context_validate_optional_fields(hook_context, hook_name) + hook_optional_context_fields = context_optional_fields_by_hook[hook_name] + return unless hook_optional_context_fields.present? + + hook_optional_context_fields.each do |field, type| + validate_presence_and_type(hook_context, field, type, "#{hook_name} request context") if hook_context[field] + end + + optional_field_keys = hook_optional_context_fields.keys + if optional_field_keys.include?('encounterId') && hook_context['encounterId'].present? + id_only_fields_check(hook_name, hook_context, ['encounterId']) + end + + validate_hash_fields(hook_context, optional_field_keys) + end + + def validate_hash_fields(hook_context, hook_optional_context_fields) + hash_context_fields = hook_context.select do |field, value| + value.is_a?(Hash) && hook_optional_context_fields.include?(field) + end + + return if hash_context_fields.empty? + + hash_context_fields.each do |field, entry| + resource_json = entry.to_json + fhir_resource = FHIR.from_contents(resource_json) + unless fhir_resource + add_message('error', "Field `#{field}` is not a FHIR resource.") + next + end + resource_type = optional_field_resource_types[field] + unless fhir_resource.resourceType == resource_type + add_message('error', "Field `#{field}` must be a `#{resource_type}`. Got `#{fhir_resource.resourceType}`.") + next + end + resource_is_valid?(resource: fhir_resource) + end + end + end +end diff --git a/lib/davinci_crd_test_kit/igs/.keep b/lib/davinci_crd_test_kit/igs/.keep new file mode 100644 index 0000000..e69de29 diff --git a/lib/davinci_crd_test_kit/igs/davinci-crd-2.0.1.tgz b/lib/davinci_crd_test_kit/igs/davinci-crd-2.0.1.tgz new file mode 100644 index 0000000000000000000000000000000000000000..d68d3e9503e6aacb667d81d5fa05c22745da57ec GIT binary patch literal 319024 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ѤE|Ygg~Zw^Yd(LB1I2zOhCoP-H$ixCyS&>;}qa>v^FB zK6Iwjv0x!(0CYXk(VqSlTro+P8s>a1M5JcUQ)v8-s)Vm-La0tlS)*u8hm8WiO>9@n9(-z^E zVWB3wJS0g%-W!-_!YtF-0>&#+p@Ei7#Yqv{QOdKFw35(PQ%LpE7TCRV>N@GGccTaG zUM@yr$Qe2s!U-|QFbpZf<5j%kM5$4Q>ft~-2%r-&0}Y5VpdHJ}uP|K#P!PO>PQ}iq zWuk#Ok7wVt;Qlg+prK~_05szCPUVXNk2K!91=vu*5hPciJQHe&=)r7dSvFBoawxr5 z4fcd@ImLcL2Igpb7BUaKAQt9-bT1ZKz;+}B-dQ&EN7R-sFCS2&?7pM%cJK;jB_c${ zz7y+_Vi2YbSd?P)&_P&G-D;0<#g45GJQ@1fAtK6&{P{q9t0JD`f{ z(IC(Uu`zSA(JW}o|AF)7Y5nb%J2W8i z22nH4KM7sHISToW*o=E!EKwY!GGzyjtS>oGUzr(=y`jSGJ&_^Zl5hgkqyXVkmBvBUYLWekuSgSzCy4UMcntu)>%Bk+t% zLm*`oVp?03)iPM?gP5Fjdx8C0cGgG0u*7+njSb~m zo410p@Rnm7mLm)nn6!)TMTas@wy|Bb3Y0;QXk?t(kqfLdjv4tSauXoQ z6OeH!7hxgURnvZmw|wjk5G}qqfpR@s=i>{{oI)7HN$xH8EQRyr?sFc)w&~&tNPOGB zmYWTtF`okk5BeZRgt0NFLm0MVT*^PZdYbpbXoi)OWQY;wMFy>E0)Nea_4Jd*~veJ1N^K8d6_N>I;<0I z_05ovTCgx-Ivt03s<BhA5v!D^=`U2vDIujxLI?-mwLvwnUHX!hefVKeL zq(uPeFUpNthgU-7dg%@)k5MWkn;7c@SM`CzDg@eT0)7!nB=5veOQ&kmKL1JK!j?6= z#}k`9mDB*h0+a*2QDq{o+uD+>jbgvno;(TxI%6<(gy~T~^DchEyE;js1EAt%67P}$ zLYB8NJ|D2ae~cVq_RL=BBH;!fa zKA47>hX1RE;7|w{+%lR|V?PYijLd?o;`nXRHsIHoZ^*ml}3K|GZT0=;SF~%?tP!@ojo0kpWkEbFm1@K zNT;@q9{S~DnP2N)1I>fP5DihNCFb(iJ4iyoJ<)v$_$3q9&zKB0FRg~OanQUU>iiTD zi+lg6A^Dz_MyQAn86TvO=z|9n6G|7#Ekfbt7g14Xh|C=$il&2o^_8xzV|KvL8~Ek_ z+q#W!lYq-pKBvjNiYw+BJ*aP7z^f2s<|87uxQ3hf;F4cot2ac{pb-s1VCW5!gIx3E zC`p|2ps%rf@34p|P(3A2IoQy!B<*P#S1>oWg-0I55}(gGYa!s)ZXgmJOe342M8Qi! z6U`9UM0aI!&pH5f)M8113EUWJJ&2H=o}Tl4nB9jfl6OT$c5xom5&6QKjT4GPi6jVY zz`gYClEo<%F=AE=yZ~IAOrQ7pbr(PgO8Da*SugYLmSYGdc=|0wfz$I!p|PSH1M@33 zErLM%oO0yz-_g4+bH=LzVk!3|#F;VYYsicmG?lw>Twi$DFAfZ^c9Aj;z3h@1u3+j5 zp|(1#JgZ6ttfQ+D_b%S%FOwqCj3uNguqTlXUrz2`R0Q`?#5nYC1C!EYQ$c^6 zw@r{PIj>}79l{AJU+>01i5hnW?WH}@2s+s(l|7X+8u&YWshGke7*0nB5^B7|_4%Jm z)G~~}BW#2DHc}KS0iU0ewP!zBqvNOnQke^Cuzh|*_AIHFDps0_a-6B z-=L|(24Ig-l&GL1-Z}Q%5Ft(a;R#U^X5Wg-nt+va=RKFfe?zP_nbY`tdJo2<28Qxi z1(vN*K1Az4XRh7BqB8ItRv{8+#^5Fb=Pjvcx zGXN?V1K36B6^lWCfXAf#s4A6oT`iup2+*iw=Ji|tk$=r6v2$f3sMUBK4Fp0m`GU{C z`8PNCHB-C=IQ{CUh($N%@my2fgL%2N{I+1mCJA&AT&Eq7Pn0ZldXY9e?4t_Q6@$@f z3%I@FV;QJ*iCGoY{R^mYmKiE}2rYVBh^+&n}KVPX3fNgKNE= z2d6-L@htpd_z(i*7z;r|9-(H> zP*)>I-Ll#-{?SgkMc5AP_q)dl;1iV(6sI|wb*2m4ZaLKBr~ogIa<`+LxRS){EM*G` zyc3n+z!I}HdRh`ePTmsLDLJ%|JEkO?qyjm#Lb&4PXbF*Qrr>0Yk=DfM|XAu$>}qgvUDI!AM^0{YL7341JP zbLT!K@Quz*4F|r)&}Oj*WC%BDyW*7+V>wD=$?HiA0aFLY)U(G4Mm+-d)@$b=fLd{9 zp4nW#FWex@An?m<2Xa$Y7K1Rj)y`p_#)C7NAD=1z&k+*)<2FAsa{%e$c_N}5iS%gM zDSUzCXxU~o*|#L@(08Ln1C-a4`uoeDNBc|YS6UfTEoI15p=vqNkJ6DbwNwH_$I54( zrAh=gUq{CenE^2v1$xwqtT40F=fUZbG|3AvFlZaO@OsJU@J>mi3?wuQkuk0jk3Mk) z7BhsA{#0T%L2ijo@JDhJGPQK#ty%=3T(YPS?s3wx!Z8~<2ji%FB+HbbTpIr%yt;`| z0cVU=I|<>oBXK09M<}GmYpaOPTm1YD34+-K48H6uVA5?<6QXgpm&Ha($LA=>&YAtzOT;OQ(pu$()jD9|`h``5bW!goIDnAV_tn8hlM)DLCD#W5l!8 zUlIcgP4N+wpb4|F4=s=SIW{8+Kra3=vayef2QwUn#P*p8EXNSeO(2hs`AAqX5maiD zODczy0zBf-w-D=*yZfwKl-Dw)zzG|MoaHv9l>q&+Los2H92c+r>VS{a?$l2F2WPIg z$>%R2Ob-|h!N?nK_Wl@gUDJbFB_15>{+yUz>~VDLd~)Ku{t0Xvev39b-GE52FGwa_8X#UMl79%O;~6DE z;wRe&Mz37AY+&^Im3ZB68^aKC5`d|-$iGBTNL)HNi{KnZv1Q1pMq?Hq&sl~+$Gvb! z_g_K2If`+_ERB|pqOk8H>aiRGnJ640M6kfZ?a~RznnoBovlc`tk0>pJ4*Zirlqm$W z>`gF~b-2}rL<$)K--|GgME7O$4a!kOAhiowl(p`8nEjfdSd!5spF{7tnHOzK*x!F? z4?8_L-6|t{O2Qrxd>#z^IS(01B(EJ0&&}Nx&|uP{LzvxVSQvNApvqps7(3*Zi}%m< z`g^w#;-UGTQ)D$x-9Kho&s8{~S$yqqS7(ih5&;ggzdCes`Cr^oE|)Nje>3b$CrvYU z_5)a_gdGX&*?eKCIl`O)`*O@O$W9ies!J%(5C9+S)-aSmd5HN!RJ~JSy1v%S)JIwX zhNvATjNUh3i2OrHGy+^3lAU|45KK+8t~vCx32K!vmV#Q6CJB@A~P#R$uM2lNU+FU!Anu=2hj{Zg`&OUugPHZXGsZ%@%6?e z9TWV@@X4t8I@t2 z=&X5_p^YaT<1Fh{?`lc^DuYCu=#+ki`u;d}J@fXj2C zVu^T=Lzm-~F6B&hvtQ7@=54&AusgnJ<}4U66G5T_#Ty$NTE}SA2R^y)hkArk=~_`s zbQrp#R&mE1|AV}HYLaa20<~SXZQHhOciFaW+g6ut+h&*5W!v_ywbuK^xBtUF$%yA9 z4`xJ0%$##v*)|0>akDY7=ybK(_r7zsk5`Y(Y~&A(`JjM^<6FI`R#T5QSI~d zr#ax;+*s%6@OFCEwLSk>*j-tDUa862b|2dkOP#U=KOF>JX~`+J&dauP75MZuH`>#< z|11xl`*415U-x4B$gxrH`O_R&&#`H7KyK8?cXzZ>TJ5Y z)o4ETmHBNh(OcHaZ{a@<+IY3KZ`WRQUcVW9cVPOgcI(_~l|oaid5w&O4e$Dz6<%gm zX0--&>#m>T)5FklEp3`^cD9}P{XOaKw7R(NVu#yqbMZ4N#jg&1M^Blq6x4a%`rXUV z%Z(Xow=A~>qx!RfKaE`NdIPrh@S;XnH7?GMF7{v5ceQKNj`1YfkMwU$$|7vuYZ{XH$EVsmMbuQbw z?K)QWbSlDct7OsM52hM`hHV|m)~%}7*ueverERA6x_p$|X|u@5{b}W2ihQfJ^IIy+1j5E@xERX`>^u{=Rwi|4mvt4PN7_ z+ded_Xl{TZfzptF{iG6*E(14wl%Ph1@xHelBIHT*=BB(KQF1fMLZeugApo#T-(z zspg8d-Ol;=Ba(*p3ek23tkxer+WiVuFd|O&SnOdH)UbsvK0X5Z;_PdQ2GiQIh4s+m zFH)WD*KL9)%YjNG!hmT$TUO1fR>#wiVh+QAdm4=t*cQ1jxw^6>1bF63VHUcm^lfb0 zksSH@sxT>Aqf3Kh^r%D4_EZa1q|o}+sv(!}s7Bc~I)kwFYh9iff~C(3KiAy&yDuj% z)Ne1AzmvyaY-gP}Ok?zNe)mg_o2A58^B<4s&HHWe332eG;2HddYpkcceqfIHqvU=0 zzYV;F;xEvdLcvLLysT|+VB*eit)vRTTs|1h7ms+V}z zmj>IXc7I4-Poumaj&LEkwlL51z4#iZ7@ejWc%oE2p}REuBK-~pP_`QQn3*_Y3VKd9 z8OEKUlY4G%{YyN$luycK1h^eE$=89FKp^_DhScMuqb1^uoHn*PQ7apSP4EkHB@tJ` ziGLW{D2(yQu{HcJFJG3|g-u23WN-J*;k2zMMex?2?j2S+<0PPNgwCsBO2BQ>-0UjE zps@te_>tW%g%JFVWCYGUdM~<>&qAxJCB0COsILRu&QFv`OX0TloE(Sy3DA@8U}2>% ztWl;U_%8RQ2Q!l_{+bHycS5%PfC?thbjAVyeGRI&+*Px^3HVUH@ROC5Gr-hw(tT8@fGdW{sf@~AM*potsedsk#J?deabYFKw8cX+Y zXaU5MRnWmXB#58^c5L`SH&v93pd}bMjnW+B@Rd^)ys*4_gDDRlrS}Nu!3q~z7d3vm zHL7Jn{LE&ZX+kfX9@sOWp+Wn-G5a>Redx7VXu6a5!&a|2II)#<1)k9ZCx2FP74;74 zF}Ik5;2#f-^hlse>Yfgyq6k>byy`L3$9W51l}~|y#EU*yV|z*Q zDIQu4HAI^Zq7>M46I*syuY6Pjz!jc)X;x?+GQ{)W58Jp=1*AU2;o#!En-tu2y9VwH zUvhGWi%J(oC08Gl&-vSGoUC5~RqvWMv1i&1P`#1Kcw-dt6#TWDYuxkiQB-^MX8eku z%jKx$A5?!XIpohJx7Rr_g9Y(W^&6jT;r+_%VFis*;nS8U+JV0`Qszt0zy>a~%1F51 ztd8OD`ujdGk_p={?%*CBN zK`!M;l(Z&!0%{>Y&33sy(0_A93!|1v&Ab|P`{S``StUZ7qh~S2;lt&jUnq!{WmPB$ zx3J&W=@d%?or_MV(BKk_hG5LrTPxLJ{{Z?K3B_Gvv>o3>%qwpt` z)*NSFWRr%6<;| zNhjKf3BRJ65B-xQ12@+T|2Isj3Iw+aglUKBigCX89*c694qo@U zsQrp5^e)upw?OXev$S~cUx7zmbS4+SmKmm>MgsNfwzB<%S70jO;OlhViJ|R`cVoO^ zVmHEA-N(3pzKXK}=X>DSN^T1NciC zoVSQQzTfgTg|JeN1M+v z?~AbN1eSSQ%<6%NuT6e?mm;oG^q5t$Y>4os9e5G#X{$IRr-$7-&n{?sb-Y#>%fb|n z27o_R|J*U`nn=XQUwT63+(#BF~4)j2Gwzj1l4@OsbJ?OtE!sT2O* z`=4mH*0!^CYjs0|dur*N&N_1auDI3Z=WqY2=>2x~T&i_NQ>Byp5$(@f|A}_kGY9)) zTpL_9s!IG!aCFrr$LJO>>zYl_%g@3%AG6WxTsXedmE&Fg+s!kFHls&XdDRx)b?Zy# zpWIYf%I3oI|DBs^Xz5j7UvK|h{R`3CDKoAfcCWRl+We=Kzk5y<*DA=4`|opKglp#s64 z(2cX2@&nq@Cuz?I<$Sz`)w^Em_*uZ31KNEK7e8U2r_ZX_s(qpOLR@+mHW|(zhgZa7 zswc~u8=dFcxOLmBZScF@syD9&4pn{J3h{c%*|d(rXbBAeAHMaYl*!21Jw#p;xLLc4 zhWMp%1TUQsrOqS>1pXcLzgQ0QcTR3Xw~*QL_eU-!f~DCqbB4TXT)%*JlxX>U9y2q) z@Mb_gIf~5kRlgOU(UEH{d5J9$;kEkL)Z4(vl-@Y?_x*_;2ob#vDS~Z0rqBBbQ>%}B zjR`V{3OeV0`bv=}E883rmAn=B&qm#?+}GzcKmI?ip!rvs9d=-(N70hF(hy|_$pw9x zh+1Q|A29FFCcXm=P7i-@5|ZqzQ)ixxP4l&BS9YK(fN#u%F-5YmYe)|$+y2Pw`-EbS znju^&@RnZthqSnm*BvEf>@Pqsg$ygXv^S~q)-Ellsjj5$&MZAzqUM~7(~iP*K%6pn zzE@%mbYIWwp2Vuowb-DuVa2L)&+5cO-N4>pWD7!EmmtH^jxy)=1*U(3vadG*4Ys}$uH!e<+|n)rElW@TJWjiLVImFg z|AkIUi@qb)y2&uxCag!II4YE#j=&;hTfQLTr3~ANy~^ifm6zcvz#eMyl2@Bx2VPO%T9Kok2nT{l=gVcF37Bn1vWDF=7mfh4`L)z7L;LTBNSgt9zy*00*gih_ z5GFC%Pp1b~f6)#(5q53S3_11X=&sd7XHg_u$tj+}7e&1j(c zfdxh#_@MSju$+1iV2~hs>2%{ecgKel6Z3tvDW9L68?d`T=G+@R$ZUe9!lpR`4HqfK zKA!*>1B8Qf^alK96{X>8U7iCgoyJTkRvLL3l3R7UeA z@$el_ZZ5{3v2S~t&8J!&*B5J!X5Hk`CInRiCVk~XfqT9B`r0Z?{MfMSNK-{rVl@&- z%wjiRAcs1u5$D_V@om@M6)tq^&Mw$(IxrGqy{BhOH*O_hNa__Ef7d=MSJ>8r*Ik_^ z9)WVJXqbGx2#s$!6cW?8IRMZKZD$2wM61BRE*qmSlviv9LgC(tl$P~$&| zpVyznPi5Ap-d;Hq6O4k5OWsPOlZnOR?AP*0%s*xZ0?H|A6wp!@cXw)fdRL9iuAX&t z@+b3ic093Mjl1*d08g^=gaJRDf(Wt;o+xDX^y?syB(9lMuRkHv$w(lTKSts^*Lo6F zzxqSfqITW%@Eaj63>c)%x%oT!`CBF6t5vGYSOl=0^y9YmCOtpQz3qMi=LYUIbTN$F z^fhS)D9$hAo$2|-y@>@i?g2b0v&Pq`f1j6naZ;PYI`gIe0}G9*^Q4f%EOs>ZU}29Z zgrx{hsU*wsqP%gWbT`a<94E{74S51PcgH+6_sbk;GTh#CdQK~q`4C}f2i&H{{blay zRLhy3E|Vft4Z4l6GE6=>Gv0Ra9tXM=%e3yQ)~JC%{cvdq*57oZSuw?+T`+3hb8eA_K^#!zI!rHd{IVyBs0S?!HUMwM zkm{sDtJP`Va=%-9Nf7=0-}`s3yqTM_dDjvp8!y!J=N{z4TSlzShyZ|ZxfNG zx*^?*$lr>(^8oj>RfdJhE#}u7o@&$B zi4;3^Qf{~-yikAwuh`2jBM(~TfNTAL=GA#3?-Rw1Wh=lGiE1M!`7#E zwrEP>1eOV$qGw(x%n_Q{#HwJk<+DQC2CVv%#FlC{D&oip!>+8nAKOBuU-!2*J{)Z# zjZSBNg35o%0NGp1jJp8PbvcRe6hhq~@B56$&~a!$^~bUr1)}xP1zZ9AknrcUbWz9% zh@i(j_ruay^J5C*38MPfCB$q9yPGhQ8a7*MCx260Wx+J5phgAGGql9)h`WKkA>@Ms zE;6grPX^$+veKxPLvDEhn^z_w?-}|X<+HkLI`M+*C5ZgrGKFl>O$EU=AaX8oo*peW93WKM8-)h zcnNR`Xfp$d=kFi-Kt%{Ycj0_j$0|Xqn66)mkdUl>17O;hQ!0cEh{ysu5^zy~(Kk+; z__DaSp-y5@fY~v_SzKIOK6&PuF#cc<}R9^Q(wkp&~ zKqNAlfYv2<0=iFZ0Z;&M9ROwil#AS=|A8~=TK~eCTaWRff8Y$n(3m^I&QvXss0t3W z^u0EZKXBL&oY@SbEoI;VUlQH-6m(t?L6@xZ3|UG?J#s+Yh3!ow?c39okBbBIW^-nV zLpJ)aT0~$Qsm80OR>SV^!KE6C(MJ#?kH?^X7EmD@nK$FizdEO2KDwX*S~mY1f+d-T zE`KA*khwQGH4qeX63Gfdgq0D!zzc`GO49(r$RLvBGx^%+i|@1r;foV{?0`0+zRV75 z2w1ZvC^bXF-!6IW_pbNF7XdG#97Ho!W~rTqy=Zp4wn>@WQGAIe7MMI~4RlBOjy?V# zR5J9vgswqT37(6XlP}>tGr(^;BE!K0L;YG%oA7D~z|xE{l(EH}lJoi#cL}DPBz;zM zbc2gfc=>E3tu_)eN7C#}q71Wn!$qU>5n7B`@7@w|!joJ=?$CyxK^AdNrQHtJ@re<$ zR)=Q5YS}IHcd#+seVk4aa29NJG;!*-E-&A@UKy$M={=xu)yk8DI^K)FKJKy{b^b5g zPuVWoS!7k1cL%Jz#%xGdSH2?Zf_XR(X}%~R?j4JhwM}(lG?~PB>m`t8BJcL5As)mM zLoZMOo|6CAnX)37ee(AyVQ2gMCHqHKvbZJ-0OrLPh??gRVp}p?PQ~LE$sao-N1gp- zGvQ`pk3>?gp&hJ;j&p!RY1bF)xR@HqF^R@8FsY;o7DFY8Y$f9qF6yYv)v(8SZMdxB zo*?8?{F_aXY5{(vMNf=uU+t%66i&?bnNaCefStt{E;M5$Ak9KmE-gsL+IuvsVOS$S-3LS~K+w zt2Zs_AYHFBibiKr%lonjaQ+_QGFLHRkQikr1uku^%htW>no=)SLuJ?o1Q(^l@{=<{ zf7c2uqm*YexSpM`0bmpZc1|2kR1$?pJSPvp9txf%j6CtI014gY1#r*){RgO&gb){$ z`=@7gARdbN+P|(TWA5-9kcLFS6f_Z7d6dJ+jW$^t0qnNQTxoALc4;nV6`k%dzcso!#nddt^eL0(VaN2 z8moiQ4KoAQUbN`rgZw!*dc?)}X1It}6Co5Q`&OZ+S3jMz|b&JZX1LOZe|lf^JW-AFue!B@#rtF|^9vq(HtO%f7q@n(@Ki6q~~nFZ+w zqX^JU$gdVq5^1WWLdQNBbUsF4&+vqZ8k6@-c8MC$oj_U# zPdG-lg5kcRD7D;miYByCG{oZt*J*6DNMVn1{vfF~P*{$nNQ|bbj`8H1`{Nu8|0RvI zVZC%wNNTVOCnr()Qi+(U@=QgNiY;UZem$v&yQ-I)6{^#;ndYT?G7C%sBD+TLuH8^_ zWs^gwdQxxQChcWdS3T{>&v>c?E~zo zp$_rITJ_VkQRaFR@c$HmwDX1p2H9sDzPdh{W)|Jx(X!r5Ir(B~-s4&?+9T*JUpF@V zx>C^n-kBi+G{H$xnoSwc*C`F+udF?O^bAJkF%Zms#e+IH-=qaVPL6oBW7^3w?k5GQz!v&(Fiq`Rc{51%m- zL9(zeLOH9z|KT%8mbX8A=8r%}v%s$vNp!zMPg0C0%a5&w6D|E-J3XDsYu}JCku;wk zDAw?_E?@x&Q)aS(olSR(Gv%pVwoky935`%B`<*@Hl2H_2=s$6In-2g!nPUYUXb;W} zmq+)?=8oZ|4;8gh^vbgHO_YZ?$AO*1B}KR@Ze+e%-eg8c;c1Me1vAW7ClBUdY8(L@ zv@KFSF?lSUsj)se+KxTz)3(@$JH))V$a*-1U*Cz z)ArnCi)jr3IprAd%Sj{yoB5fqWFV2_S_?q3OrYCJ?UpXsF5wsvybgvyP9xdx5#va= zE?f7XIJ@u63gk{#=bO&GyJ+Y(4r3(G?3eQpH&fPQP=9v5J$}qF6bBV;v$lLtyXr4- zQ4L($t0GSHZ?!rB1FxSfgTjSkfM0z?}@*hZUQ&zMBbG|?gjn1J&7S)=O}qUbcXF! zPd$$lUi*yTK>w@GK5Mc}#RT4=B>_FZaX5-gr;S?$*#Cr9J2VMV+*~+0jx23(aBk6C z_pg1%&~gx>RG*xs&0ZDBk%h|#ToCtRiU7+naQ?gM{MtfuH1!?W5IB|b7lWtH zw5ai|G9hTp-Ax>uO*D_1dd0&_pI4%00h`sNHE1l=nsV$8j=zyU=Vg0>fXM?OV^Ya^ zf5!Un96ltuOjrSoZ7}MUeVgo2n-8g5LyXnoN*Hs})j_zAJ&FWtf5LoRF(Ah!V{6x{ zn;!qeh~ZuDA2|bU=d6e#@sFHYRW7n7E1U{Jdis|(t6c+gmsPCJ2OG~? zobR+9igZLD53IzGtey!tsb?y-++jGZuDJ0{f;gTptxj_^Y2X(Cx}F#h?~ec*9*3GV zH(>Wrnmkz*7?Jr2RV7=P+%zEut{NnA7wyA}1CxqvI5Njw@+Vw1R{ez-?-Ww8o``Df z$4R6m?cr7IhXrO7_a=Py;z|Q_oAPu*2zF-#6&lk0C{4 zLY0h+h%lo>gxr?kEwf}EKfe~ywV}buY^mumJ>Kp}=+G8AFgW z$~{)d>UHZDCvId}-DN@&CME^}hD1oX;)z`!N6ccOwlDephPZBaFj%q2euc4l%`}S= z(8+=?5X8Sz(D)YcFa%O4je(A++eng~hwJaJoI(^z=qP<6=}2uNO*SqX44u62TSD>y znm`1#t5>c{qjVhLk+&P)cN!r@WCx zXd8q%w7ECSGWgO7s`o4&G2`ANGtAQh$iVzFV?|;*pmM{A+rXItcTsr4>9bJIpkka} zSc;=LH1%dmRI)Id==~CWF?E|WMWD*BD1#^gOAP{!Fgy1rxz2DW!lFgK_RDZ+=-()izTvZjj7%qJM5@%IxSP0XsUtKSmJsVXX`4g1u1lrwSX z((?7UsVX;MAh!eS8>nF#1N>F4txaAKCPn1ngvnNfHNUSNne-K+b`H>KGHrPIO2pj)^XBA23PEmnZi8` z2JRoy^QIM{YxWCmY4sXCv;VqX6M&vGMuKBN`tWDrVtNOv;ZJy1!nIYfHf; zVpb&Ytu~I8r4RcBk5lzGVF9Ai3ffuqC6O#JGc-{`HX%;mBMwTF{s7n4PML%1Q!%tT%wb>-G4VtR56yUG>x1`wh<8HDn#a>x)lF&s z3Xrm1FbmbmP{9Y24B+l~lYr2%-XmhuVZ1K#i}GSrV$oBV*`;W=>R=wHE6I}_2OpjXonjVnemlbWgvhzH!% zJW@lTby*$xcrOFka7xGvW}G9!@Y@veT6%n6PmN6$6CYwLf}V1uUg1#B+LWw=o+IrI z5qPuehbbpSOLC_rbd>ZYHK?SoW1=>3qRdF7B#1^J_f-hNtg99j1QY*NS$l}EE8WiV zg`!M9GV;!X)4~a-&6f((AI){S{C&rGk^?CwW2`Op>dkE(@}& zKYPL2YxyYAvw;WI5+cv4T3=Kkg-W^w5m`Dn#6aAj`r#p;Mo+E z+QFnv-N4bosr;JX?=O)eMld)^^1vZV7g?DItfv;FKN*e>sOI8ER84{9CzWdd+SfF) z#--7x`|5b#t8Q50WKd(X4%r0&!lmZP$^M%K`gvwB_OTfV#gj9@6vcnM>$iO4XSswp zl46*Glz67NTKr;3=%ag@DwU!X%%0e zp@v6|6PW2!yr;aDLj0LIEHpUB^-ej*qc8-b8*q*$j?<)@x^ zXox8+(O$Qh%wpeIE#wn=rn8rzxS}7O_4DM*%-ZuUV<6}a7U`ZCHOgem<2U|udKZI3 zQpzh8;K_VTl+ao#T^|Kv((kIB^0H_W)gBDHDv(8Huu_lD`mBT*Zyv&CmK{c8q_b;- z*;M%3trBIOTBCvx&H~vOls|Z-Z%(|a>p>|H)-O;|DFPobu>DxnlR;D(Ur z<6?jq7*<_PEHl1z`!gZ)1M}Amk+KY5?IB7wCWg^W?}4&nG$W4wI@ps6u%pa@?*24f z(U`6FsXkLWkwF)rj8jaB4hJUD#CH(3e;RsyYx6d7&gWf)A=D;Bm(k_Y7dlL ziI&6j>Qw$?USuGr&Ja+vAXukVvEs|&K}&}NAK{7-%L)KoP$kGqqwzIC zj?e>MMfH6V)4#jh9e+NNfbNsLiPV0jpT-^3APyEeVeiX%Pu_6RbtLy9*Lp~7VytO~ ztidD64oAH)(nndK{W5~m$S=dXKmQpiR~^HjR$JsTk)V_sLt)yM*A^mM-)GbmG%Xlq zRRhc@df}Lss*m6N`;Iz-E?C< zmLh_bV8>V!3Vi(p(7X_bgV=hQS{1#k6utol?&zhzC)Dp12Roo}JkaN-IhkBuONe;ln)EfGJXB{GzE}$`$6Cl3I}!yNAi%F*rS-q(VT~z zk8oAYv)0vEzcUv0m?H;SuQDJa&bZUI%CpWFt!5HcEtTG!qauU2`^Pc%GiRx1q~ZMy z5$JBfWdThv2%Ez5{&C3?B0;&NOQB=eu2u0|BNkPixN1P|z5zA;RHuM1_M(& z$ac`)Z69Y|Baub^l@O>Nb4=q05(#sJqInXC6n6p$Ss3I@uEvODO2H8y4K=w$G6%!< z2LmQINCP5B)duZ7SY{^(xro8s^|rz|=*BQmX1aUA7%)&>xFOYGqyTnXZdu{JyxLqYO) z>{>yyqa0outWK-V*(3>nHpW|p>{xK-gh??gNAgt9Oht%WCN-)U##w(wDE!EPERs0I zbBf$bS7q{WTg>l>CXg@9jTgN*I%09WaPbdn?$S$NA4OSS z12%IM1;uWN5)K7Nao0mS6Aq1xp(bK1PXLa3M+DcCEX^((Asuif-)g7}U#I|yU0gA; zw|b_O^-GTJge`y@WXUvs!m?P&lGA$jcsax@!~)qL-n1EHF4JAn@we;H#%*GMb4}(XTRb60Gwv+?7dVed@P)@C_INj$oh)SK}qk4RBPLTZc&^(7%u*GvG$s~B}mECyVR__Z0vQQIF4j%Mp zc8xuAJ-E(dcU^c#*?_9)@Tn-LgJucO$+L4DUd|-4Da?5mlLw5X+OZ!s8Q@kdJOEBp z2o~3EFr_Sv@Ea8<%X8_{=)FpBYDusPxluCF5<3T%Z0nv1FAbZ$g5Fjw>ztwbVLDQm zMjy4) z`g2S-J?gg8qW|;$+v`~8NDXzCrIXc$q8D;JMU-nP_*ff)s}I_3$8;TgO>5=Byff%; zgC>Z5Dz1|>hy|73%|^c~rvze&Ww50PZ!pz>Z}Ro zdgOAUMq$(#ti#@O7T}FM1$yla1a;GXfyAi=BR&WWHsTrKP@W842unW)!MJ!8*wDb1 zb4DA-DWP{IxZ4S4_BJ!Ts4p=$4*7*nQ+er2$?6=j>ZOq@?bn}C3FUhvV``#rD;4Y5 zB5*X<5L55)Y5wIH7$RAkLUQ+c)lcjJLWQ81jnSwSh`#X*Pe*x=+vEg9pir2I_U4pU zqClF-z7xL#oiX*kR@UF(Eh4u4yR)sXzt_s`I+9nwaVu#UEw2c7D62pje{Ss|Hk6iZ z@`1<{msQSBW*2vKINn)Cx#mN&qTu#A`mDC;yS1u>z8|KkFi2lC15S z@c5WW>a(@x(6kgZz$F*=giq9sZ-?)l0rh6oFfAsAGHxo`u*M5>4)r24#fr^dC&5as zH0`?va7VOsF&qvkx^pWn!K&OYQX|u70gn=4Dq64>Wt1*jpg~Cf?@1%UT=ge|7TxuX zOqN+bu-^s}7|keDGW1kUiOd*jU=%a3FcQ~Ns4T8T;<%C54ZWB%Q52Dv?9M>kLNYT} z-b~g5jmd#IQWweSN-&htKVB|oi}q`XX7M&&0J++Bf$^B@INREzu%8Pu49SUjX*vfN zX?pwaxY-jz-zc*8PdyqA>FTsq7ENp5CXy@v3%@^U1K7k#-q5H%M$nE7MA+pRYn!!P z>yg1{RUNaDL+Up1S|3pAKCBl?x=BVtJheY5u}UHECR9uq_DR%5q#lLRI|twM1;J%~ zJFZ9g(B~vCbt5kWn^p(?1W+#7JEK(8-zxFTod7wU+?=#jGPaG>##ng3Uk(=D^9Dy0 zg7q45cym9?U=NBhlN4#0QWSUrLt0`nK*JTt$y1$%+Qaruu^mW>G6wUoP%0F+T^{%h z@g`OjD?vKSRE%%vLPI9R1s%+w+wGz~mU9jv#AuXodP8_*+0u&ZEIf1v@u?|`G;^X= zAbvKa2PuW7pAo2tQ%RuKO;j6Y?^mg_MqMZoL_ z?(!O$o+g3tX)?c|dSV=dh12y==<|fTrU9dYdZnEA@k*(0`PAKmESG2s3YutaAQh7V21?ZI%b$ zHj=zuWfItsXR*54E6P7)^_gg>hf)0i6O0v*sVW*IUJ=eD5P^|Y8ASQqBmQpw;DG1n zFOC3Hd;K3oO|!Y_8$HdHE9c_|#Bl>nTQH(=ZlDA?qTjmZ6mk_^;2hCd^E4iICp}w2 zZs*!qjgHfUm*UH`t$`s0?l5Y=qNREjqt1(p6TjZjA{Kvv;FPS_o(tfkdzQlb~tT70kc~0%^9VhB3>A0b_mpy&CJ>0|QTl&J?zi z6E%M&A8~xz-ws?RA6+B;lEy}a_RRRX7mP#`TlU~Zf0KikQP_!hefTd3A{IHc1Qe4t zG^F6hW{Kq{ZN`98kBc4+#2M363a0pu_-AzEaLwQ+3TP+)$jXBPbcRlLOh* zv-@2{Y-K~WEQOM5nsK|CYnPf@3(qAwa6KY9?xB{&*>`eY(Ib%QtygBKE&aBsh&Kk) z8g!mDuH?mXg<|J|uIzbK`KP=u?^8C?$f*0Wge&po$Tngog>_@h0b|w$Vya!fz(d>( z2!Idyq0G&aNoq(ild89yH$Q(}ng$3J-e_EoFgw%GK)U0K{EkJfTO}YdeTdxlI49i$ zJWwA#7hvKlYKb!7DFAK*lEUq>7NBz1%(7-ydA8;WQuy`A$RdnOq*J0C-or)>{3#yX z6=?7w03*!%LEiA8aM(cONFJFENYqd!V@$~@c~~!DN)s(IV@^VK^02>fis8ph(d4Pz zY#EU)>VZFrq|V-eFv+5P=abW0%BrE`m~i?+RQHNEvbNAzILH{flD=SSMp1Hw{u&|b zYxotMh~Z9VN^S~I?T8$;F{Hu-tJ%TZAEG2`(!#yEQ{eZNG!&Kvd z6pcyf-xHcgL`c>1;Fu@BfvzD9R|Q=(BvJ{N4D0cT>8i?au_ZEYLPLj+N!X$&j!{w~ z13^@l2=yqgYN1e!{iH@ry9vjBM6<@?+(^#Zy~swD7jNZ@05)kHuGc>VEwjTBpcKT0 zB&iY6Ga1Ba)mHPM4+~yj4cR^bfUW>Y$=mfvYWthLW*+y}6w`A8mx>d|wrG5PX-BN- zC&1D0pX;~*rAW_^dKpHsDP3R%`Ln=nn1hv4rXsA@=`HrvtR;v%TD8ahb6BzHc}0*A z%MO^1c9)A(qovqIpSZcpWl$7RB`%?db`Y-Qeo*kPxg#P1BUw{?tNtRIUfMRiM}z?J z1MrCl5HyuqNLL%8^vd_I?Xp_>&l^PS*Td$cF<*_QuH)wfWv*F@0Fn>xW{SSI^} z%*~d6cNKDlV3o34joE3VjpVTS5}51OunCvh9~sbMua#lEi9UbfM2Ad8bJG zSK8{HjLe0n;5M9~LifUG2D(@n#MB0nkH)1r8ucZ_z2?gN?>cE8wGkFMpFR}Hsme~M z9)v&n$Y2Nd%T!}aJz|1mmuEzE@J$y6m?0j^qO#620jS|@hWADI`j)UV64RjWyd>R0 zpM&Mu2v*xcf9TIl_Ca#Ws*F?l-*bJKx^7^8UZ29Do1_R4j!#H5dD||8B@NH8b@h;n zm&gUld&`G%c@}YUSRzV3!$_Ju>o;YIgO4iTXJe=B4_qoSFp!Jw)Itoto9JAKVr)h2zJ*vlkp}M;bBfmJck#fZ5_*VAY(*Jmi%RGc(xd!y z(nU;E8}s3h-grh1Jtzj1P>+nxv7Fa_IOP)FH6)7K$)-dgD51rj^bgYzcFec&y5I0ViN9GK`ew|DqKe9d8M#p^f zhIc}*iYIjYwHQH4!qp;tG^ib`tmZ0;gCK*^MvO8wV5Du)N>2)oA@`wkSnYFWBvI*lnx zarme9{qw7$1Odd-JA{w}`;_NM29ev~)c9`+kdZbG-7pk|b+W=~Z4IlIcGp;fI9IBQ z0_jj{wIkg;A)*ioUBT(rk-N)hov33RV-WJy^04vwP!~W0NtMAjY)EOxB&dPA1;o^a z7yrh0@>BB%OhVdlvMxAU&7a90Cc1c2DKk=Qc+<|DeV0&^+l0iY4VvVNrRESXgsfr7 zA+pw)!6wDCY?VNVGi0VzXj}77j!+<8RQs^kMbtMQq`s8<_Bj$NU-x@|n!qQcJ}@l0 zYJW`8oz(L6ue!x2TGI!&!uU@EvTf2D0&p}_fdD*x z5x6dF64)V4f*gCqlL|Ak8@!!*D^8!Ipne52AZOG^JhJJGroFCUm)mJ}66KhPGVb*< zJ6sZ@v!imki}e}iwvHKQPT`BO>O$@;0E2u~nEToDIdPDFJ|j1MLT6~+p2oE4eGh`{ zlaVuxZq-n{^y_lFBJ#p0I*18*aRoL~kVCasvHWWTmH=S&aRh`^y&KI83H-!(=<~_i z?OXBlJnRl!EA8_dZFxu(@XZOwXgUdj4Ya}c-96nfFms&-R2!htbByZ=w@hi}fLUm} zSyQOLy7KJR9F-yR-)djdV*{NuiA8ExA9Xep@$EA#9%Nefsre{T=&=n22rzU{6 zHjAS|P!xD zMU%dAY?46JW&5wfJQ37ctk=lC^0IbD{`uL2F%HijPg?aq-Et`8b6~*ld<(kSjAw6I0K4VON=%&#|eR8so!tR$nuf+Dd5%4|L_}yKhYZ$0q zVi=dH#tit;)FEg3kktuKi+6MQ7`w?(_EYCYj*ymNm$0XJQFQ4yq%2KJdNp0~N^;L4 zz8M0^Hy&cv(4QfoLE1{4TC;v!sk~pbm&QhOTZRx=T4%=@UR1CXh$zzpIT>s|!UDrz zb4M6#G;q!`e3o~v3E`R~1xL?0lB*f%0hyc?61=oZ@_uV7iA0YN!}jMypu40+IZ|i| z)W^m2&z4er9XSmBI|AEbA2p)gA-%nfg$jI}g}_9q z1bASdfd1W3lQCloKG8|>83}CbWSWr`8Dvk&{8_14V|(&=j|1V5)mmPG==TjBW4D+s z-KyPW_VHWk2C1#C*&W}4*wEJ20xX|rDksktm=hiv$N{*M)1(o)^<6Zs=fo~;(*|PP zGewTnjYs^t7LSf$(pR@iO{x{=J0Jq&z5@4VhK}i4PlwYV9*+H60#=1-VU$*5p@I(J zzt%(5OwK9J5?5BK!W{s}9do#WXd6IHkGxah#Yf`#ig*#)~ z!OHjF=GYFJQ}kAc6X7v6g8uM2>%&TJY0TiUOR}Hx#?NRj6ALSg=0%+0oqWfEIW9BH z2xAiym&Cic0DwHP_PPc-%+Tz@%Lt|DQVU0aT)AF z+<+u;qu;4oyAjyDs|y>t$$8<2BAG$8))V=|Z+q~8!3`5;clHi^!#6(Gpr(L8NpO1J}F5Ym|s7Hu^=$st*NmA2u78ESIOp*btIzhsgj=;Xg}xZ$(oj+;874BCTwzNRbZ2 z!%1wTs!tc2mtEqjC2mI5L%y`iVlK3IzRhE&5lbu;H(9QlO!PxfBgPy)V4spjLfwt%WQ?1G6xeC9~rx z3zJmdc}tw&sQA@{{M{I9%XRAT9`jgzOIM+e1;1#7IWz&G_!IhN7VEuAe7B$!kcJ^N zeqjbF4|-*wz3BJw>{P4c<78Xym`+MIwMuw%oI$oT&SliQKp5BX2ctE)gIYm;(2MkF zD{J6%17M?-?Q&@)>m{?gy4F{)O4~)fR>{x2e&EG%oiDZRSvSiGpG!YC!l|6MFTNV~ z=j#K1q;Kqd4ICEPxVqYjqJ**P6P4ltc=!J$H2Kw;=;$u|Z$gt=x*wq`-uE@r&nO0> z>;GM7qOqS{LGmj!DVHg>2wnu51U?Xe4O5zaGCroLOuJzeW=sD=%Ahs4FfjovM}hwogH=+l^U!9blEAWt}g#Pg0Vxxc@IklcbRH zqar$OK_|Lu%lQ_eOE;TA*}EC-F)gXPpWjL|4b_ypWZ>sfrE|@tmW{6~AdW6|BW_dl zayH!jjq-dlAfHsuI2+?wL12i_0qTrw|6$Czx)ggWE5tr40n?ILWB4GP#|;Av3#uq2 zy;#jZJ=Cu_Kb66ox;DN@xLW6dzXu-^|I1|({~wo$ddUB9nI!HUSs++?(}{iseH)$k zG9((Bg7(6vc2sBUo-~+AzF|V7HX2 znETN4gl98S(saV40*rGwd2Yd#gI{CayKx>W`wI1`3MzJ!Ll9kApF0B*XaL@@DS3YyOPu!l(BP0u|t!mlto{l{uXlktzZl^>zwj zBF;T9TO>HLaLczmg@PGSD(zE`jzl82T{t|@$_)Dz+ zI#&c%(gzZO?<@O)W7#LF>2kKq@=iM09$SdfMO`2lWR!9orRqs!QO%!m*Uu|9>^|Ti zmu~1|qF-jZOD4i5e8_Kdb8yXn_-y|PcxX=w&|kFILk2$VCdCTWs-BTWL)bq)6xC?u z;8DmLY*6lEsJcF21ctK{3iCU1V2Z*8GjTM^r73=*#cHjlGX3}9nOvdZxRdm z%?v@q*Xa5xxIBbTS-mav;?1T28jB}naA5yxX9SY!u3Zqp8_vAKznwmuZ5c;z#vQQw zj-e>36R4;I*n$4JkrXRaD#fXIq#D~WZ(C;7MlaFhV*SE9It+eS^s55#mf$`&p_f0RG|lAavd| zQ@qRbzwcEC#dl#%hxO}r7fuL~`z|%~j4RE8ei#aYXMqxL`E;gU9ewkG1qZ?6sU3KP zUz-zQjVoK3Rd7JHBWiKsxhF+|0^FNuP5LgAw}Qs*zEDDyY0n$4sC;fa=op;)YeB0N zU;zNd`hqLVaQT1{U^ERAqWTGKHIk$uuw5>ChZz9^!P4~RuT|q+@KD7vKHGC#lj*#C z5f;5nlfdJkAp@rr^{$q>LZ5u?)zE-mV#U|P_KBeCO7HW^#mt=A6PT>@YJoUp|7c>AIht`T%k(c2-k#rjss%I+t|@t$*i`ZpChX_-(q{V3vyb4D?h@n z+YqwRg;AxZX~voIpBdW&zA&7s^1NRPXNN`J9~sRl5YTp`!q7DRPvj9**oulQiH>@% zQ}N(if{W8-8rdVNaQu8EBb4x|p_wdKaWX0jNNeKRUcDN|PF_a7WhkBEm}D{7@` zacn^fg3+0x!%It-zWSaq{JgSw^cTdbxV0ArPn?E?xY+{m3L?mjaG@O7jDirpFRq7Q zhsI4CRcgmExGIXOUK5L-~H7-v9xD@EgwRE`I89N03dN`V2a(Ebli&d`LG z+eoFGAu1xK8|@Uel4AUH*3jBnH&Z*_Xe>`abkZyWLR$2lUwkXC&_0=<<7i=|4Jw-K!>wQk8ASBzbpbB8_$mvh5D47lKlEzry8if zBv7sKoBCMpudc4D{CYiYmM%HZ;Xe#GPzXb3>Kjz-ayB+_V-nsE4#4T3uR3 zh@C`2y|<&~k^Py7bk$tcr3gQWj`#bg@Eq3uvO+4;v0Epnv$&&kjw*Jp$(LIi>6FKP z6H(LP$XB4cgt;_Pe#o559~~}f8yZ)Dn>>VdkT&Rnn3L?1NlS#C@=2@}?zAUHvASMN zhakgvb!ZooXIaR*E`-r1A2(&)67#~97B`vom2S=1-Ka>N+X_}AYbMuZvy+~$sXE7l z2f7St4>qpebZZ@Ky-llIppg^Zo5)6G{RxP=!=N;+$Lzsi6-WZU+$0cNA5hEKHG;QK z+VQ{Ah}110Xs5yMkeX>HZ8_sn`_Qf)bC9J~i_Yua0d?V;vcp;-Jj1_bM4F#!5uZ=A zH7pWRwNz_in`iN{_+ukd30|W}JZ-sr+L zjR_e;f@N@-ebiZQ^8tjZnprNEWw2AqY61YrTdU?a768DTz?8E8>CGp%nkO2^4!~-= zpG2DrWxUK}>PGS@n6Smd0QY7C>VGx@GyuOQsf%liNhz3%@>jF_a@eg6ouF(`XLL?& z2-;h~ddRf?a@jur;j-(MuOkCT7*kX8|BK5u5Mf)bmzI23ZkH$5SCS@FbD`%r$Vw_Y zfxXdMxVsqWm#{&H<>D^+2FEmJaM5!OM5K)?cK|xNwB$ku{IgyYkr16A+T}7MH#|XN zMv{)3PndsgU`ZF4VJ=G@fi)w0if2IYKqhi`BgfqrAcd1VmZ!$aPK5wRg8s%w-m)A( z*1^G^Dv~Z8T5B(Ba$e;QIqZ*_`6hO@Zk<*Du7uPc!I4jv1$m(Z4+Nu>Z!++4@3DzM z=ne`QYz)g1u}O`;J~$6B2KJ8IRt%I(fQc-Ei4F#J;eW%P4oQ{|4)l8uddz|z^c!a< z!XnlRJG|gmPaa(1wo3pl1n&hFmLXRjq58$A=iz?w>5+T!XLwOfrsBCJ8A)KXtngI+ zv4-gQm_}Pn)`Y3~mRL|d0R@D06Ar@>YXN0Af6tyVJ3-Tx7obSQY&psJUv}DF`QOyB z&1wMMz6@Q)IXj;HC`CLup<@|HFaio8bY2?F=fNFPG=H>pK8W_4O8$7bk%0v-+{1s9 z)1?0)r)Na4eFPkrNZ5A_?~*sEA<`1XSN-)SK=d74%ErWid9gWHb4XjK=YoMjEqS+l zYtJ2?W~kS&`?>w`iUa|ISa>~tql4g_Y<7xLlKB4_I6JI}22wtg1<8~Ei<7sN2*}Wr zoazhoH;G_@$j?lNPT;YvtH9Dj(EDYlpMKftS+2hk@}Kr>2oqX}#MRitfSSwd3R$3M zrVc8P4PrvhB*G?q=*mh1`@Gpy(saVa9$_=_?(5hJ zZ(&EA^jwp=(31$*@IbPRLM^0?k}F0H_wmPEuvM@?IVR!^&nk2$Zs20YFY4&7T!c=Oi! z8aNgE4>xZE-%J&H8zI&{B`>JmWH{>l?{;3ZoV7B@qA(s0nRrY~5SP+vc_7pf$}HVK z4w^JWm=RY4-3IW z#yBDTY_txJa%R^D|6?g65N$GrePBch8zh=itixK$F^t#og|l&&?#75r)h%Aov&fM} zkbDkq6hu#)cweP6Wmt@uRhOPh($Vl1c=p&0mJAY+cl8XX1eNEA@LPi`$xW*6A=wpf zVo>~V4w_pPqh==0ti2)K1`FmZ5+ZIZ(Nwr>Q5U5Q9pTYHkowNJ*Y4F)He4%mfV0(_ z((8Jl4D8MzQysmA3Wv<&we(-*Fq_Fzl#ME8)v+bfD1N=CykTh5PEphst-xY3c~-rf z`6in`O3})n2?L3O!m#lBqQ;Yrp=Ea;-AW+evWD5(`h)`j28ZA!Gz#?+{w4}=|^p?uqrY)F2u!3uX`DzP7zL+!a630=VVWW5_boxa?}TLgo$o+ z<&Cfee-txtP=QSw?;=p=g`&lsX}8w81srJsTx|23Bm>vqwkFM$Xh4h`RrQ&j>%GKG2ME6w{;D}*;RCd+Z3Bf=vU2MdMP@qhvXApxWefx8sH9!vlrdaYinnTj^{Py4`c+)Z9=?bQeVt`4!$Xzc@}INMk9uaBfh2M;?z1Tzb8BJC{bL-w-#;qj*{e@zLZ z8-~q`4&Fzw~s7X9ON;jsep_AvVx1&I;P&k?Pctzmtzx9ttz z?qpQoR|G8dP=4?$Yt+KFETqw_*_6{Gf%Z~$p4s1CZi-$aNMgAe?qHV=#()Up7{uvf z#5`c+d_#?jBhT-0rP~e+h_+v|nk`WrkO%fztK1N{mY8Au9tvD$mf-(E#|jKj%T{K??9h(H_}V_Dj*SLEw8*7^02)i|azK zbihspYjnC!nTAS;y2l_|?}dVICCFK;77U+#8tT8BAyG3koKIb)L%|m^ank0ffMEpu z>nKBBYvj-%Y(+KPN{X~G+WF4-t@-GAy7i#jvoiSi6omlu45qp?jI%NP1>11epnFOB{pP`;N z(p=0A08up^UMjo*6W$nd(~Eu_LN7Z$G<~}e2c;Y{j$T~8SAMkNPDW!G=67DO-V-Z{ zXo`}|9R=O^KHwkW6gCjJx@Y%u8=5enhKkH9E=JpUCVqsw0keIPEr;>Op)|#zfo-O5 z(N=6cs3=Z>xSE7Qgg4CJqX2zwfJ6&J7BieX>Z8`VvmFu$#dUrE3hx9X6`(QT{)VmT z*%%P$OZ;`*2l@zk7mZ*_x$L2{SX2eL7lBaWZ;{L$Zp&>x2NM^%R!iVNW`J{cevo1&*@EDA7 z#~V~MLaLVro$ss^v)30M2+XlO3E^ni4dZcU$!U`J%h?T%&Qw;>QIm2Kkgr!5eGY@f zE|jrl#x%|-UULo8k(cgWZ8X~H^pqqakFW&xextZRbbj(>5sGF^^QJSS!dXQO6f)qM zyAT1=KsQj-`4Jnwa{ch?=sO`X!~F&hde|@(X)U)LYtYBGJEU^#e2#{+~fu>0bCl!jy-z4>n zL8bU1i_=F2{s6qgz?I{sfp3Tk@ImSA=h+L~qe+_H1MS(ha-uu^gNAW5D)He$wD1-O zAL&7vAP%V3CClDEMeZhzsnB&9I4n^xE zwPSzu;L0aKh8nYoYNMpWE&f0*AtN}5kM&E}yaI)t_2NYv194ACHCA8Hm?HpRQ(T)a z7s}@80q){^_S)2o$ZyAtBMNPk@6IH_rjPg+y01r4`oxq#71#$7763E@h7>ao7e{Nw zPWs=b&gDfrV9mk?xmvqbgOW_05h|{rYJp1-L^6`hbLNsD^ZY{zpOzv0n{sU$6#}m? zh@nOBrZ}I7;|B&;BFy}2!*Jj{lBMM>S(WbwpLyl}xbjU!11$OzO#@ot8Mx0co6b}o zNtGPEn0mYuy-uoxLYB0F(J&IX!RK+t7)NX3X%lJ`1QU$olK9wMU2vLd%ySGwuYx5* zrw|To`1ZIxy4aGDl&*h7EINayrnb0&=+Kw>USo5ZCy}+)f*#d*Y~EUeGx4hP)`vmGV@w zI%vp#TqJ--T|#&DC2Vjs&7dL$lpvXmnf_m|)PpHQSYsEt4q)@?{Z+--2W1>QKPW`j+hT9yScc9&vfX^3poRzSHG*C4cD#lR0?-q&=My* zjY$-6iQlbUTvKMI^#+zXZ0pj{Bs3BMBs7RQOySw2cGdSmuWR44k)N=iq|d~TkT3y2 zAW8W^s?BS4Qlfu2zqD8GaRc6DQ)lk`gR6m?tqJ+=gjG4``K1zTjuHYnkvOi?-$Ka* z6jK_67<}pw^3~BGnX_R$u0H)xO8iF?nbFMf{KAN@Xc#_j{66YZHUFKF5MroJQyvq@ z>$Lz^*yCcmD62WJAn8b3P}Hrkvh-4nIFVcmJSF$3{DoZiQ;~=SQO&i zh47Q}yR9Rg_|>u8f0vEc2@L5qnf&-o{r!{AY#}fCw)#6^yigB#t$5#@=t;e(H0u46DFeYr685GJ22hs8s8&3-!lZ@vzH+_jiRX1hn zx&_h>cfDGazATrEfsC4sqR-lu2gc~o^%;V<>Vd;KK_GZj&R=&VN|D{59U2Hq^h+8N z3to?vw`_$J{CYFd#OcJ`$_q&~L_P>CE8=L2i#yJBKFedA6(}`Pmd3%|#g*wSD^PRj z$`*~2g0Cat%Eg10MS|xIxdw0!*Q4T^VnM z5MHi6VA1>|eC_7I=m-7scBBVq289`Y(FOS0{qXsH zz^?(ixPF)ft%(oqhA@Zv`(dt$YMS?JMu$(qrI;DQahb$!9Fx6dNGI-%$R;s}xJqwtPZn_&%8y_j`&DR4XkzbL>M}T`Rbt0!e$h4LeBvN8#$z*DXV?Lv7a&vRO_ zx&?C&T0CYYO-!XyVYeR#wxK~*6XAC382 z#s_9Ct8{nrI%l$cFrJ294MtTrX>w>4P`bu#RB|b#fUi05Dznf^ZXlrkL#P{$k@PxyQ4bT zduRP9`LnUC*fPgV*bCID!E$pVC_|oMw(2S#wiqusX}{1`==D16l>z*-uaSvO%WxF? z6W0_G&XF3hUu|Tb)#0ho{bRyw2k*+Nw$Tph>I+413I|9RhUOrNztVi7JM99J9+u;Y zHX7N12|w8xYsB5ht!R^{wn(V#s`xm>x^fFhF+39GzdKL36Gb7#PzffPaq@z1ll!oD z27s3L`pgvLC{MT&F=1;2PMAYp#F=D`q}wU~dk)29eBc-CRr0sQsV|0pDeZW9d^ z(IzlpteN*O!bC5O(QTprk*4nnP)rDq>wzE+?y|fR?h5)!W2d%%MlGW>9uo*cMxZ7x zoMlwXHsc2Ob|M)?OH$Qb$)W5~x(`1vTj=J80Wo8pj3HhNm~P%THL^>piUw(gJe~7d zV)B0n8-4lc&)~r5A~Bj6ei0V+O%T9C9wYeq@z>++uWf839`P2h)~87X&mOw#XpRQd z+YTeo66XaVOnVLw%g+@ol^A$ubIF08ZjtHXlILSNU?wf!2omo}t9Xy-gesK9B`tU8#M^N* zUumFKIFJdZ-b}rMEMz|oS)3yLgPTw`Ts#ON%<8j91jTyuR?%SD?iy?h(M)5TCo8-r z4C^m8!Zx?B$(evZ4Q}i>G|69SSAHh-$HJ_dMo3RL@NwyHg+XT!UVihq#iI*_zJ0_H z7N250a$@9GK!j8Z{e$?Exlx0i9S{@6FNkhsh3-FRK9l z;E~{BU1W@Svfj}gQ-D;;L=h?51q6BLgN+Qzif*V=Y^#mgA2sRA6`Y6OWG-ao_Z74Ltu`ChSS5i;|H?ets=y`(Ln@eG zDY0#fE3M2!SsSN*&os$se9%vsha4r=2UpZ0^@8%mN%7+7$5rNZ5DJ&n00c6&TJfb3 ztHZ$=%`R0>DV`<0sv0^&Ubh+-3rCW;yy`%^wAc=+F<4c0C2yuCR7MeC#=>ph_P6){ z@_g0T{e14~nwIwXXrcj6+HFx|J;FZUDSGB>&8`u)vVi>-&5mgHcE}$+X(*s)@S6(Z z%Gh*GfgPzsok~f()3>ZQ+MB&(v1;3U}<2)%W{SVCzcGTim%C@w8-qtgTD?^ zqYy59GM$8sP~%QoI8?@`Y5t9N#hTEoY;}r_J0J}6DQmrE@43~3x?%n{w1YpexnyQg ziDKcrT1p4BeBaDptuO$z!2Hl+)8$pnv_LFv3YhL1V7jRRX(sz8ZKb=vPacSFWt2j3 z%BhMN=(T3yU%0Uv$18oXM2hUpPFSy2=~VH}+VR*_k+`ec&EporMB}9K7=Dr}jn6Jc ziombXNaN#l^l()_ddlt|ia)tz^*wSW50>=A! z%{gV`SAl4V_=p~>SuH;UC7tA_0fbgVQ zk6)@bo#y7v)mQoxFc`S_qd)bcgIxe)m-*MvJ}q6~k4uXBfvZjwPF+YuFdWWpAa~$K zO}T^PbHgO^^4;M1?0_pT(3!x}U3(D~`e}Y(HW?CSH{l+R6Ur*b^JXM4TM&%RN8s0` z^B^{~5DMw#Ya4Ha+h!V-V@=VL=?42bC_po{Z4r+e9*jT>ir)Y;-FM2cX2)-7!MUgbY1 znjRqNE~jXn0*l=MCG0faz>>6mxl2%aPsm+tK^@Jw+kmuvj1r1NTJ>_Jb5?av*j=tc z4NutAPr?6MrRoNe?!D~X`S9hg?PCB(NS+?eVsG_Q4BD1zO7S8qp?{kQGpfKfHK?jD zM;-L#^auQe))T$jahE>IR?abtjx?P3#&2d>>Q^>yHA=(%f!{YFs0GJh16B!UUw$h^ zR|Tjmm!#zTBS}d`;gb}N$~93itF%t#g?Kul6yQVcTNe0biq_g$F^c_)uWpFgu0s1n zgVMQxNh>ifcX0UlsYTY3Gl#(Kp$p2*p*iTQCAibp+Pn|yZ%`o=FqLMTE2%E}k8bf6 zoehtk^iRn(tew86BWSI`adHdV?EtQImoLW{t~7g|19-!|d+u6AjGa65(h{}Z0Z&gz zAU3Io`wLW3Dx`uNjm%MTXr&?*dGM~=o}Og zz=B~R&;aVi=>%}`t+TgUqk_J4wunjxthTM#wb8-O!dzmD%l%E5&v+lLqzE!0>@(&> z-MENOm3Z^*Vyg6Pk}gka{rxh5`C(W(sTv(93AVJ2$YxOy7R$Ca$2cd&H?cTmbTW{~4vMV)W8z-v{WJ=w7V7OR zz+-X9Kh}v(Cb^>jzy#)tsH%zwPwSyNINIlP%kf4T#v0Ih{KTPLe5q+5d}_u#r|;p>>)Q%oi12Bg&HB;)B-ri@_l|$e|l~yurbviX@MDOk6r(qI!K27 ze0{|=_yITh7`R?{D^NKm_u4>y{MH4zqAT+R_ptG;7W>{MdwRd`3KUNL&hz3_@oK7T zu&LqkdAzm#Udwx1O_$ZyRxjed4VI()4&Q$aml=MFe2?r*(A}VwKCphiodh>GuQZNFl@G5z|W!%U0TS`ptHCkixQ$FhB}T)74Wq&f3~ZR zexead^7a4yuVw#bEdcS~fSujwS$x#OH(@>V^M1^vHzFG{v)``> zz4pCEU`G+6x=SKO9aKvfp|9gQxDw*vk?42d z#MewZSEopg;f3B|>dTlXm<>C-MZ{jPK7cR-;fvM$^jZGB{izh`$+Po&X04-cIvGp2 z`IER?&a-wE%6BV^3p9H%=7Z!0H$(mYmDenVVk1P9Y#LG$uBAe+{yw+=qMx~Cs;}bI z=6I&A08h}R`-5hsj1%+^K1qT9Jj);A>$C?V#yf8W#4|Bwk=6Of_St~BHKlkWAB1I8 zX=}~Fhk26)sBA0h#uXOab?v!WbCB>2FEwD#`CW$?W^;a&iP&lBoEV<{uCuS;uJ_x1 z!=uN=o2k0bd-VzX?)L0ocbnI4$X@H++-TMHDE@2HV{z-kkY4LMJ)G=C9ahVMY|X-F zieHUB4SdbtQ_&{gVD#Fj zLu0qw&sPTC&T_Gjo824yo`KP{&6AspvQzY)swyWKduGP1Z zsTI$)J)ZTLm%b~_KFv8fzpu}d=dq-}8x>0xw;pqJnzQvgum85W_1>OVvW^6rPorDm z!CHWCPYAHQcjNYW`!wM-$*V5W={L3P)3yx$(dl|> z`A+$Kzq<5nbaV6dZoeJbjNxCc%If{Y20uu7>Fo2J@4PeEDkAoCH@^k13#xfu?dQJI zDtnT{Ry}Rlz{(yi8k^f&?>T^`)^zZy3zNMbDI!0O(|q1Ci|KiK-hA5%Qt)bLYJR5M z7Og+w@$fczy0{&5Qy@NjZmkwIx?Vfl$ysx_+!%V?p`UrGO6fUb&;9Cdd-p86_pU*3t;I=$-ceScbzJiVx2!|qwP zai-ttXRP@`zunadCcn48j2k-ot9t2PW5%x6_r1O2v#`}PchnQK>-%_E+|m1Vq3=h1 zhQF!%S)r$O$@{seyX9^O_i`Qc-nCjZI(af-d$iwVhTr;g#%85fr`O|<_vN`9i?8Dv zCbY)Q*ZJ;peLpHX-@v|l6qELZUdCm&HKrFkr|XPwTXXC7VaIh)zU8aE)p=#>%T>5;U#-NK)t52Iz9PwP4=g^FOJL}Hm@pmIHX6t+HyGsUuj zcmlhp6#ll^=TPmKz4g74>I`_wDo>{;k;pi3zEW3FfHwNFc^ z)3CcP)K%>ke0Su4p6@Nz%}rhByyv?(Jj|os%aH=~?t?PMmLl%2o&RalxHQMGQW{Nq z1wss_IN!=o>%tGL?dy)=rdFQ1DEE^BK30tW_64!KMq2hkv*?Otf@&X$^xqyH(r&xA zr96Q+M-h3m$KU@FljD98%*!yn*3B1yZk~=^CDNHrjxGJ;A6N~)LU%DZ^r7pZK4~3ybB6?9j-%A@p^(pWZR8_gN zT=D=yO04B&%ddcr34#UkuEq?I(Yk#e+_tuq>s0+Z#+J0UpA#&4G&{$flH-jDNKlF6 z0G1;;W|O@h>*q=OQ;F_rkDvoxG&j}?SNFPJHGeE9TBjjbTsYkbTW%zaw${p4nfNMk z`!h)WrVA4*Y+rQ6zZ4)%C|UNT3NT~l&b$fyZS}%ZzF5L{E>Gr=GAJ#Ij6xzPdE+}R zuI;57+DHo5ND7_CyZ7RQ8=c6r(SDesoGbs9IKK&rrztHQPhKZXr`+mO+lJ{ILI6aE z^7T?9OyHzTG&GOufsOL`OT+ZFEX>TfP>M-s6@Q2OmSwB4&4b5#GD<@ukM>uB{ab{Z zhAWMa;GxlF$cy!y>j-3|I3q~?e(sEm5Jvp~cok=* zSHTBJ-r<`6oqT|D5x^JD^5{g#p7!s#cYKD@J>N@z!zOR(*}5~<|fHGID7X+Qsy3tvwXANeYm3?|^v`-pDI|;UEGVlt=49aob6w2PM+9YTlQQ>b~q+!ws{IZgFl~#oUOhFXr6A zm-O*HM&T_d-J+EEvACYe$jJpW{81Hs=+npHUEV=`=)t6Asp+xhLehnF z&4v$-dcB+p-~fANbx`Z)b9sGq(M-nII@~$-Rpp5Oh^-Gh{|J;9$T{V!{rTp`G~aB0 zdX`@fp>l4YEr{<1h)yb?YMEYFu8$*(!x_FgN`icqzVth{#6TKrFDAC(-(5HO)<2!M z;7^*w=Vlw3ePtAc@M&l-UiL}aS!G>@T!(0lc!@F8uy}$Hy_NqOsF0~A zw>QyFIrA$g;M6cwQ%W7))ms#ekz}wUX^L*d$*z+3Y&Mkb*Kc2p;;2XrbvC|aOS?|C zw2iX@|0n^|81{-mTVX3T0P{AJE1>JrEb-_T zDx#^=XHOC+y`?fV%0N@>-cE#Ceri>?P`E7VMIk3qb#^sA?N&-+Ent{B?Vu4%R-T6f zX;cCb=f^;IsVm=SHFb50#xMF}Kn7wLpkXt#zU#mcRZh%PK!C5HFsEh6TC9IqxDaEC zxcxPhzX+OE>DaRcE~(b7?78jO^s4o)SyS+QVV(GVUfJb~l6J9kyUL~NPSRo*$?hUf zv9sX&LVI5F5*E8rU2ZQMo#}$Ph||iuVtepAY|AkCsuVicc#rwlYr?YM!^A9C(6ZFe zYQ(qtc$!5VS?!xAGWsN2qW>`j(Yn{*`u4$k`@JkZpw>I|kzRn6vyTcSq{2?s z&O;sz)FjQR#RT@kcz&fLd2c#?fKM_y#b-Koui2&;|3e7!cH6$X!9% zm&>qc)$lp0$U;CpH34nEnM!}dP@ziQ93(ZHagbSa1p_yYriykKgh6BAM-r>Lh~DjrD$u zrkD8Kel7*zg{|`SqvU$80@W9W!MUajEu-3c1ap$d#+pO`Zolh3K<+Ju_rlFuTke;n zCUV0sl_V(w&-p(FXcf zka&KfO7O$?z5li411jPOEA&0m@1dp}!`aZ++hnE=nEN9UKyw4I_iff-m3_=fYP2g; z{5Ut(Bg0wKH;?R5K$hiA7grepCo9@hg8F$CdfJuj)23Tay;y_(>od#sHrr?JkOtLt zL-nJL6iE?ukkIqPi(rk`B9taZdpG!+1B&2ysx3*@l&mO8_JkilSL;E9g`XNN`lJgcRmI~~AdAJ8j3EhkDHc7CMwI#?v}n2!`+Ap~=PqSbJu zslqM9DP5+u<<;v4#7Die-XP7v&7qFG*0qN^&N3+92DOR>!y3<5-^E`@p&UI)sVLG! zAw!eEzC~iF1vqsa``L7F*>w;8eZ^g2 z^Y1RL<~u4V>AgzSrd`R3#qU}-^BI$O&$p}P=kGP)C)(j2c-PR5s2@Y|tdq zUY*NVo7R?4)mz6xHT*${RS(C;4i_J9bR6BqLT^{whnQXceTi#F*ZcXK$nAwq(f7&7 za{)5wY2Ef5yw2@%j4Pkr?bCB^4PNj1<+a|;Ei9euUb{s=C}=??o-BKAvxm zMm0VremBv&Ja!vBQEoMlQE63AwS8Xo*tdQgExs+edEf7EQrB_hGfhfmN_&sFI?dSz zUH9i59=#8jRqSIy=5v@f_%N2>yHf%zuif~4KE5q@&9bU%^t#P$yR|WJom#kap4~pI z-V3+wL4Pmqy0!dz_wMHAz;AyK<9%0n++It(o`;*xy*nPqwqp6#YjS$e*x?4LZ(aPp@?8#w zTE)b^@8);mb-}f+YXUsh+hi~E*s5oZnpinv#NzV$8oY+kRhti=b>VY1qr~K9@msFi z=CHgjuUj75z>A*kP0X)#+GF&mydGXBFIM(KZVN@{uWdA9MmOumy143&R-3|3yYw@>U49i zdz@VL^uFHBOPpNRFJpAC*gDf}_tRIuqug%k29w;|pT-UxoT^;9SDUix^!#pade5#m zPVaXG?D{+$7Pa?0UF!Q#|H0ePe=pb7xZwL*)Y)(|hJ8GXe(78)7@a&Aw>jKxG{JBA zIcK$0t<~+e&-wCLj>XY+4Ha1B;_G~MKD!$cpRHqG-j7bbM=j;FT_4epnbCK`x2?W( zeYNG-FWd0d+UU5lcVc(N)qTu8x5Z|)(3{D%_Evkpeg1vSe{$t(z8J&id0g6> z9q|0LpW^1#;cMALV7ck^GD9!Buljy&Jw&GMyGB0}4Jg~X-(qY8l`yUdr9;|FHN<+5p?du9}g3m&OSM z@Huz4DO-fA-t_hBm&VCiWzL6a{q{Y$jq}@^bqz^4*D%!&dDTJ*%P<0jL@)Qi*@Sk4 zejACa_rp&sljdU9-K8RPl;tqv+V9a&uUW04z5_AAe07pe4Nw!FIMCrO1BD%4u%^>T z%aG!R{;&k_5{3>&b(UstPD_|PVyJprX_5R=FsbovZESqIyZ=l4`gm{J1NQ~yFy%6c z7uY}e7&Hb31foh4dYe(J!2FE!e*sWHufGpY0M6VvG(BJ!@C(ol4kvOMmGFP8!1jI+0fx|Se2W4ilv-Qw}xexF+$7^YY6fm@f56t1%u8c!ic?96`(@{ zc|S2tmWz4UwQTT237Klc@&>QetLxc;z4pKw@DXjF>^IL=>VK|l z*Oh09yLYtR+-Vxz$e@uj-+=o#Y1Wr($&e@ehlUt7JHqhM)MaT< zY0%5niL+Ww#zJ7YTusJ+bGvXn3zsF7CD7Jv;k`pR5DUIR1gO?U<41kiioSY@2(=vqx=8ASCYRcMIjrY%y+ zRoFm`Y{wK5rZx7)QrvvaR89(ow0n44DK% zr%=HJk6BHAv-^W+w73LKuf$s6TZ;qOZZF#0>})3Uzzx^_#x4Tpi-YZs--pg}f`o*U zO8I#(W;bL2|c1zRS0S=$p!d+Tg!d0eU@tl+C zJT+kUOI7;{Pz$`D)FC~$+vv1Z#VZIukc`-i4`L5wuXfM29>9@WOB1fF=#2>RfAE%T zm$mjwt+VM^wf~ElFE~KBm!9!I)cg1PrT?XtOc_ByZHegq6uJR2z{6eE#(;0AFJu;; z8s)th1-gG+`nYuaaP)8{9GTZfH9hkZ5I7K*$gB$Xec!^xif67FM&|?Mv8~C_k?t+e zlMqa%kv80nW6zv5_J$woBTGE|>>k<|@8D=2-Tl#oc00!54cBbh?uu^!4I|j|>jtKOqyjq)9EvHp3;N)p8C+QY5VxL+ zd}znpGefwKhGz!IxLv|=`W`TTEI5U%X79vU10?oGVV0l2XR0$Zj2rDFW$DW=XajvtPg`5G^Aq@I0zkC&IfSfwNpYU)l&*^DgZx{=1sV8NY$rZIq_azxiF#Eb|+@IS4QP=0g*Q|)Bc*ygLI3u)xkfxaPhpGHvJ;bKSp71QBXOduLd|&h*1b!P&Q)IUl^8~bv zjD`iAEDpHbb|O&uzcpF57aXb)k(+z1$jg079p8mRz&{#_az*=F-bjNCwdTS%;&_r zv@F;MIB=ogosVp1AiMw;MpH+LgtJH(QLg@k(``$0qx6`BUrD!M3puJUS_3?{O;qp` zp%!N%E(MBIaN3u6gES-Mh35}p8~~4Twt#P%7(C>=)Cb{RWcem(5_U9_@^*330YZ8= z1_pi_)zI1~Hd{Q(a?KK@<*_|@WeqtNg1;m%X+^e32%5&H>U(VJ#l5q13#|ia^NQGB zlxW*T6Izaox}Qb3@5tl3qh&+nhYMK}g%hJtavW!1+wer5n3`o|c9+;pgbsD7=Ms-< zFg<{ChQTHKq~7odsm}hGc40*HEfnh_q&7-V2KHLwibPKl3LJ_=f|U7LJfEb=}I0QLk1`4Jt^Vrbg}%;53!v%`Z&B(yo~E5gP@4ZK|5!=5vM&qPBT zh{09g-Nd;mBcnQb!;Dx9V=v8zlr;b8lu|drK}14@@*YX`gm1C_ z7Cxk5W2G5Rq0Vf_MBB`0Gx@Y<+l(xMq4bgJ3nZMVK?q|n8-QC79SS!i`nEJ^hj9m( zTyhGqVDXNNFHt9rc=E$S+jTrfucYe!upl(mylK$%6QTgzmMm#HQF^B&7Kg|YGqymb z#lDe0g364h_9W4eX?yL%QbSmnA?HQ2ibiRwzO76;i9*}*6Wv$NEQKhEE)du(F$$eN zKR!R$kw{MKxet4Xdi6FSXH0||m(4K%Eh#FY-080u2ZX(46iR>l`-HIDdOHqmhz^lGd!5znkn<<83f`l+7I#EK^RD)}tZ%3OZr%g2r}WJ^kmOS( zF-SaZFqCq}k`L7aZHLqgYU4v(15NAw)yHKP_kEabX3eU7;_LQrPr2Vzqq*w0^lwkq z!(ZNq5n%7If~?r0>tk>zx?C?-mzIIgp;139EPcF|ojrX=3P5cUP~6-W;Q^KXgl(D! zKJ6oRDW5P-LN!Gc0=~||+CJ9+w8JD=L1W^^)fIOLez0<;35dn787hOlU@>PP^e;}PruSk4-Rusq%0&hVIB>ay*=~(j}({n=h)$HgF&-hJ(%e(ANN zeVU%EhU1?aD_{4>X8X45D+54ypfpkH{_nsl2XQd)0fnHb`GC)(*(iJr+9y)GlzeL@ zZjloxaEiIT(kKb${*_^8h9`Tn6&2VlN8Wo{tKoDwOiGj?JUbtRj5i^r1e_R~rG zoSpes!cHi?!xo*gad+GnKbtw1cKF>Q@^W6|G2vDyo;5{L*YksMKd8gBAvZA{Xo_TA zy(<0ta8&6lBJ8kEThqzs+9}d*0p#4H zs>XR3lU7+dJ}4$yx;S`tR|I1Q2C_a%PgOO}gat!JL&8@zMieauv`Y5QHpv4hVr4iP zaM(9qct&;cCSfePw8Ja6SZxn+w6PDNwZ@OTJ$O6PJ&fopf~Qk1O{jEKUDMMM{%(4R zIBmF!yX!*>%_Ar?H+fbH5{(9~qb;obnx~s)!a(LTWi9I2?F%v|BnOf#^n>@oI~;$m zhflJD76;7=|B?=Ej5=3JG?0V`rlm$>QIAt4P{|*14CdfEhPK;T8uOG!BNFRCJs-~8 z>GsVr|JD2pgbs8tb2Hh-W!%P3QGq?|!Y{7oDuV*7=U>lbpjpkNt^;e>xk8(6%6l*r^B7-Lol&E@3Mq&SLCKIwDQ zYi36uaf<1|rAZ9VrSC`NwgI3=KI_7Nr-gTq)t1uDlkBPF)}i|Y!Zcx&p3`z7Rd1Ng z0U2oDgN~gzAh}n=#ONKw*YtfFDsv#|6tW=$rdXzg=olf+W%tb(fO~GMp5w}7&c9fF6ikmRtCc`}qI1d|~07;mAkGm^PClM8ePL=dqjwP1( zx16~%-0ES->Qy0)`izTG05d1oqD0Mw<-$2|!KBDX{h*2mK|Z%lOc5$ltF-KZvZ@@# z{nq#}l)8w!b6TP)pQOA)-1hinyRuqoVxG~!py3T3->4b*ZmqKCxNhd%w2;gy1=vl& zfGW+~#7nJONX*As$Be%~R;cuR*Mi^LD49`moxdp5!?h#&U3epb{9mC<3y;Fc9}MWF z6_}j}e#Ra;$LBcvkvE<2l~yr8aS)KJT13&0OghMx4ZP+v8E+zDgNCqKtMxTrHy=<1sf422P{;W#OY@P;DzQY;s`#R23@d)v4D{~ zbgG`_#yob3Px%gKyqcxLV`1E>ncWvLDUCgdvX~)-5kwy67U{MwDI{!1pl#+aT&H8V zM{PITV;~LcMct*xv)=O|{>7R7{+^#W3DBNRm^@UlP0pN`B0u?qrbq=h*=A%hlVk`m zm`ONPJ4rtH0kOiL`MciP8JMh3yB0#jP*m)1@;#+DIqgW96kPYx{hbM75M;UcGtdAe|AU!P=7ik zHn(l{z?V56Q@Hd^&yF~~rL7bU#E!^`EYmzlac+8Q5`wc%q0xQeb}Y3#k{!VvmL?KR z&lo87TZY98Jd1wHw#43;j{@!p4i5Dw)RN6-DU0O5AH+CMJ#%y;oi_EvZbNta_%))P*>Bx+ zNh!}7CYVLfAC9SQFoytgKY(|88d>x(`D&}T|BM7O8%0Fsg$i(ZVl6%!+k zd{}eyq*-eoG>&Q~ua1qOh|y7IP?alpX-04qpxCYt_h4FVRMJL;!3r9w?Rp$?==cPG zZ=JH{7zq|TI*Ala*5fH zT6Pn#uQMUG5K!evjhhIssgc*tpnyKU6o0;R@n>5ecR|r7t5On5`b66-?{_ZrWjWMI zw|zR)5zjpV%t@C=8I5j)mK79CM#+VeKWfDYloiCP?T~#D1ubMLkqE<&qMg+xuuk`% z-3H>HD*SC9A2xG@zvHdbwCuM`_X6vFPC(cn7Cu%vux=`upu&_m$th$)Ya_`-09Y5j z(f%^xpo@t3FW|_{0a;JXapSTg7RZGI&2L}0S^bl&9;1c$o0f&xMw+29)&R}K$WKL_ ze0dV)EyS~H%PO;?+-|PG`+{wo@`#Hvo*7M(@_KXnD5IFdFQH|poC-M3T4H30dgs80 z5(~;3d42X&-RYTlsvDS=Jrz&Mv1aSrE&s@mnkk^|aRHX|8nC!beKnERbC6bUx6e z)==hRiVN8iUIWaJ4HQE{Ng0>&MEwH(W~W%_9nn}?<5p!DI})z)K(D6*d|-2Z=J!`0 zr@?!=Sz=z}r>TNQfNUrmQb4M|$qa z0&@mLa8@)ldU)qTPaIb8T}24kh!w6of~H!&!7$L+^qep0SnQu1t1$Mj+htrI46+i*)&Wl%VM*+$4Zi$V_swL zf-|P2y?Rx=)YxFc!-b6*96N()&{V)iN>9mQ5*YuHD4elyS}+p1@qL-T*O@qb@*XuQ zFRKUh4WqP}7mhJ290kr}r;}PnB6mOP5v8;*8nO{pMt>mhWWjBt9GO&LB$J9ua?WB* zE$}5H&gpg*P1T*%NB}+``LP_9iu>aBxj+Lw_A5kp?6DoDoZ!Hd$D}?N;UvhTo1X2* zp^F)AZ7d=sCyF2Ga|$};V4OeUYg&M^cesEdXiS-R$5=d>Vu?C$ggQ0hHl@Pj*c&$y zOeczd^0<@;IDSSIa@D+!5Xh>EYm`pORe-UsOg^W&WA{J<-Y`Lu&6Uy{|E=KIu+raG7@Z)4CMVus1C)hBOvE31`fC_9o}sAinJ zJ+7=&P9*&@((lj_dmE9Uhr-8p&Y|nHoRzy|;!cW`!e7biX86>_Eeu?o>Q1McNCN(* z=c~%Ad9pW`bI%g=MjmxmgjwoY2IS^Iw3Z`Z5hy|KkI?}G`VM;{GEk@C!ry~@hQ^IW zVgVRpYEGDLA44`HKNtx@MiPJ$Igk`$o=;8D&OQceXc=iOa)`?a-Y~+M$$S(h6`1oz zeX{k+u8N*t*CAUSPXXF=9RgIe{I0?aXDS-S{Rw5EG9*ymJDRYR3uxgs{@;w za`^QF#+A*)v35VRP0)Wv=44p$^WBX(W&!2K4 zhYlb|CCpqyW1{a|0e#6`Vzp!{C0_fgp>iUP>@9~QOc+(iIj>hLD;t&94&35Gfo|8Z z`|ciX&#?QdYe!P>zzLD3UidhRnWJNYjVPEha#9D0t@ibdGD%q!QebQOTr>%EOvB6m zI1{qeq`inFh3VN;TaLY}p`;(mXka|DHAv{(%2(HFge{jpMCTktods;xAwY zgclw_#En{kITT^-lxijT&S7Yg2DBMYLcnmr82Y5 zG%(j2CGteXkuh3so{nLroL2IHJt@=&%m*bUObw7x2P@}gW0AbHGRc6(+NZx?tO(!q z^_CD{Lj28zmE=av2Kjb$J&13f2%nGlR8J_VS~eDMcHqc`Psr(6<7j(#1`n^Mih)Lj zu!3kPF~g34YZ)F^9A3v&4tXI=W8!8FH3tBe5*!5I`Hcs@Ir~Galo-2KhZW4FLJ#^-Qo|evjZH4tlJ6vhuPZ7L>Wvg71O>%LAb1Y))%dx08~yx*eTw zj&IJGhPMceo}Dy~P6d=ECY#3T>Hf2$T>+iO;&a<{qh8w#?52oIL7XrndOOLnG#zlY zR+;QoexTS6k(DYjgaky#)!)-CGg{Ut;qP+2ac|i?T*y?D+MqJL`YVeu_k;JCFV)mo zymi95%baPtKN(g)VQDCbYIX^yT@5gK|1Y#NT|4XyeuOw!EwO5fwzxLW5DX5b^esaBHM5*hyiqb9S+7_{XbOxc~BP((Ti zlT#Lh&pGzw`~7Xux4vC>VPh|%;Yun#-XL}<+4%~{&m}AOb(Lh<%XGW$Lis3gI)-`Q z7X0Gw$=Uw#QK5VdNzyet+(sv1^bDAWawc_1H0{ZIzEOj~kNmdps^T*8;*YU~n6Q7~ zJHjX1=VX1DVXnhg&GLA9Ao=G|B)o>UT-e(td%=;=6gfS`^VxXOtLzP`u1B0qA zhE6|rBNaVP>5#G5r7OEEk4GSC-&NT^KF1giAY|=q#RlE|&*W4dNivnB>-E6x@5Cw% zqsXu(B%0YVHHCmQe-}&7kUN3adu5*4PM`cWokRTMaCXB)wKQGI4<;vENCIoLIWLFE z44`n7@zgljKRx3@2{I%2Q)Bn_(M)wk9GElA4SFMqks3*HPxTrg9QQ9T%eQQS!dbYi z8fqm~v8J_Mk$e>+XIb+T-$=(5@kW%M8*$kUQ8}E7ZrHAKnHY`}|9&BNTwej9_TXzp z=xrz4S-6X9I{g!lF(|z}?lQ73xe(VEueOh0Z~akBV*)gEDvXNvd4aTM3!O)^=DsY9 z?q0zqNd?R1_cKHxzcgXLv`1Y1g|H~e9$JNr>w%nDQUPddV99r3n=NnrUEO#(kK1z- zILaAr&gxdR+yv*TICl}|ZmJtnFrm5S<04QIk2~#54{OhBEFtl^C>W&i5lL$D{6vJQ zH0P7D>=P#~D^KV1;-sjcLgmE#go0_v%}Io$R2H)!7*@gpX;VRdhDF+=5ASwQ*iD1bqq^t&`97AAz=uZ?Jk(Q~F@VP@2j(hCY| z?qPadQxt2F(9Ge9 zk?_r>-=~bwSj!ORj>{GKmVgO~hJMcWgrb0oR_RJZgO~{lWl%DQbgBLrjLdRZ+X-nO zW#UKLMJfmG$6U}cBXGg#QWe$EAgztzd-@SSpc=fw4MWx=lXZh=Y)9_jHL-5qT`quv zcc@#5e!r?O0;Pl|Hgks4KxotLNFQwCl`t0~tWu@j;`fK!E3GG!Y2bpd`fdYQgoTd@ zt~~dg&zOsyVV{a{Rb*CdnQ#*)oI#lBZU_mo6KWb9PtX6hs76MUs>Rzgt@94lkWB#T`XZ9P&6aMeVfJjaZw8Q^eZUqV3j z<8F^&RP7BKZGbc3ZIccU)FC|!{(LHUjW*=;dE?*!_puKxv=nKX8Oc47gXghaqC}YI zV>-re-_&xOC92Qy4Mbawr)o$>G;`o_FgF}|JfQ=GWd@c}f$4YQ7cw}B388<46UEYE z^eGOU5TGJCaH{Yu9Y=(=OW#vsSH>shAk)b8oBbj8A`&gL^eLVNYb6q5}q!U_V`qU*O!x8j+7d$GENQU{ItVPWax(zW&{bw|D-(UbK;cCSbL^17@1kjd$> zL_?A?;xDJ`QL-T$>tZ_{Bla-ft?QxD`y0_9A_%Pc<4j@|M6EW5U3K4DFD9T2FGtWaV`>V>iC@diI@^UE`fs;RT0%>92x+`k> zT@zEIkq|%H#5Q6L@;OipJaSq};SZ~9_w4os5trl`lI4ByK6r=Y&&3Rnj?YBdU!i6( zJ#hhWJJe}XRXJIoB`|h;^fXXU7FlhA9R={@4><;fnE1zbJLABf%Iq$0>EKd*B%Li0@A387QuEwAIw zeN=1A%ZY~gW`?`K)LyDsW0KUNBbC?a5a1-;#xx!2Ogr3Lv3C>3(Cfeo)=1xAR+DGQ z$i8q|6th+pga9$dZscHg308L#ZIk|uNqHo6H|+~HOy=TA%?WRTs6>?{Wyerq(&l2h z3BqcICt5!Nm&c>udOCseNe}G3o2gpKNN*+l+uma9g_{nbR;&^ixM=jL(?A1zT zWvY3U^90WazSL(f&=ox-*zN_$F~GB2SflKBSl&7VxW#(qK;ra{g1sWC6L}*t;b}Ef za$wGHKx;D3TTCgvb0UH6KQU(gkxOfU&s<37Jd3 zcGNmk?a&y(C0$yo13Nj1LPM|!0-z8r$%1$!sf%)wXJGwFEH$b~ki<{pAEzQWLZ^+N z_Xn{{^%)d);xoZCtt6wVEyMl8`j)DThN@&A{G33|f!%SbAh&cBPdTB4(#DAemJFkj zuTWrLm8|b-Aj-E*a%D|ugE(A5#5lKkkfi{T0*EqYbNSiv$=V%6NivU7I#Hhd|VwS@x+E^fLvmjal;0IUKEJtJnlB^-iHNbdl83lkzp$U z@f!tqc$3A@lRi229he1qrTFB#k_9M{05v@enD&PSYE7xTu#~X}zr)=D$m{r2uF)wBWq|khs>ssYI5D2Fw0F6En7AK%L_8=F%>mf2wP6c zDeN4h$R>Vz%q{42$od(&FYwG`P0A4F4VOk1OO>a?zbLQ6qE0RXPfr57GNIEQa=PCn zI-rqz($`Jh#32G)aW@xGK*)^YngSRLJV&=#-eC<1k&=&edOh2`#7KNNJ0)kwl}b_)AW|2GF{RcG@KJ`l9@iRZ2Ze~=4*FV{ zqOkn{1R+0N9n4)t$sB$QA^L8vIm;q*iqW!9s*+5uD3Zu;<960!y{5+GB{Vcg zwnKvJ+L}&@1+|pK=;ug-sM}?!Hb>;!nJ)6BMDL_E3w-0e%9)Y|jj-Lbt=Q$_<6Y-+ zT;9zFE3)MU>+zVJ2w2#pGIfsb!K1StiSJGyRdDFU z%#T-Ut7tpb8QoT3n-?9l8{w2!>eyHmLRwv`?AUE%wZ5{lDf&M$j$x1B$=b59@>u;r zmfRKN$iJjN$&LBc@7pwiWwe!=5nB+sc)S%0o|MWBiM0t)*GGQ;(syI3*rHl8uWCH1 z*ZT*s3REv@YT{}a|GOXGJoNHJWR!E_#UP=z$gy} z3UP&%U$Uxp3)4%Xw8bsocWsQlAii{o%=cqS_y>WVgaeSo%xb{QL>4phOGdWcHWgaP ztXx(eFlFjqV39&QQf6s6$*0B{9JUOnKsOsqyNxX}Usg^(8vQs9jFBG`!kRpb)D#kr zi=0p7%|gx-DPf2loup%O+8vq#I<)A`q0Js$>y-YI6r5E0+c4Xh0bFF+BK@U2Kg7{u ztsfQ!fu{t4WL@*1sPtVefTvo#1Y&+lA@+L|ViWjnh{L~O3Sv!;9m5@*!c}pTea`-7 zR5OZgF};{L;ETw!5YTn_;_xMsLciD zvJwvfitMO;z0muL8CDqdDuLL@qK3GQo7Cex**?n2DU!~Vq*vtCFocLyeA<$<03}ij zVOXB(-1d8CdKmG<9+Es_RHs4dl`>T_{49B#+;XhcjdW%sd@cBWO=bxbpDP^nl#@G2 z_d71R<*WP6PNIR?zJMD=`dHf?Eind<1wBxHa0;A__boB>EKer{TWE?aBX=j{e1-oj z&~ru{be79pYeK~sacWZ%9}xDOFwYqh-B#E)3zIH^Se2))6_A~Fpp*GqFqWkc90hHC zVhq(_v*QGStECSay4}Kh=5h~kaw4yqIaa zF9QvE9h3yTe$SsXq+9h-S`)k7<0$OTm|QTWCQzYX8Fe;gfud6c#*lo!{0VND2V7^(65Ud=Zd6M1Oc6UX3096mE;bTr1?lv1gGJThm1y)wL!*=U zw?jVi+rg)ig4*$Gk8B)ehfiChid(kzSy%X`&Wk4>PQ{a`n0OVN{BBNEiG3R&=$rHM zaOsXJ?7xp!bwZNCJo6vgzRX#j=5dKh&#OqNj0OcI9=JVM@6{Vz#kmu*jP)&(vy{nM zZX-@zzgX8y)G}#^GHFYhw53ei^34OT%cL#$hW{u7b?aB;)UO1@5 z=%}Qe?-eXy>oxUuKbrKYqZdM3Sl>v?n!I6?)xGe2I~kA8JU#G0+$1ViBOb9=Dm4=`1| zFt9m8Zx%&NZJcJ9FmbaC90j14o6U^IXQ5hAm4D<>XyY=ONwp^IV`2~mm-&Hh2ULM( zqD7SccJWp6Oe#SH(}q)vJ#veqV5f}tn-njr>EI)$Lvd(Blh{dLA4Nn57!l%-h{_kQ zJ$_{LyUUtEoo*aeHuh}Ma#=aDNk@a6X;9>u-;R@94unFJPho^IaDt@R7T={Cj+VT) zf4r+24VNTj+$c;d(@9T9&XgP;a6TwClWLig$cX&HQzj=}4EcC*NVyYL8Tl(+dbk#r zhdG>O2#nZDZYigG_Brx>w+9R3G4p5+*xs+2s{L=-qH zCol^yHhuje`*=_A#!oboy4lQ^#Y4lmh1&;rOZZGi1TmSN$1HPmMP29Pz5=|#2`1;# z{UVh%7;x|WMLJGNnpIM-B`F(E;Dx^Iwv@)Z4OAIY7pl2>>>Ks zg__c5>;Q!~IgsdqMW_=exy+J|7)$t@<7gAQQw|V#jeV;hf)7E??z1C}-1#IvWicgb ztq_jpAQ(W(lx@;c!35a#1V7Iu1K=-tZbJE^KQg3qQsP1xl6II5jF1S$TbToyXb#Wb z41gOr-#$Myx`Elps7hvW#H9H6L~;!H8qds3#V!yE*El0urp6gsGK&X5uP^krF#k8A z8U?fLf_=b{BbRqe#T|vZoT};JlHC!80V>5A+I`%QHl^ll`$52gW~Qh6HpMcnq`rR` z&GZP4qf0B#6L-_q!eBgkTXL=c#2f~kNQKylv?oA1Fl{stQ&xb};bI#U;6x-6X*%B* z!UZcFT1J6ne2D_#ib~m6T6Q|tOKfx&i=IlyNL{MeKL5X{=(acDDv+NF_GJ zpDV(b`!|>p+$%e%JyBznfoV4gOv|S%g?Jp8n$F{tOwV`3^z5!O)3dt9ywB=%j|Lrw z&F%bUUZL`(X$MWZopZW@jrUg8;sN3k%wkG8W9Hy}iucTkbJm#+#GfM53eqd5C<1tbup}EYg44An zX|KaXYSJqmrdTfeuy8({Bqe4ut^lP#ksI*$swUJG_8c2O@8Za)L=m?u7YXFBGCep+ z9NEqBLnB*c_*!^kY?Na7VtR$+dHQ}uoPit6ao8rC^*I<{anu7$O<{>AW> zD>_Imgb?%QLtGOExqHaW`?wb5Fh5jk`&Yu_&jOE5IIz9Y7Jp9#y|Jbe$~4oB&}OCz zglFL?ogh5PtszvJ(g9U|k5USbeL*^r<(5fZB-P4==MO#Bsc187>Q$0qXzkT8x;~&L zB4az%UHpXYNjfA-4_TTqxlXXk4^?vvv*_ZAb}^BVBNWPuo*xbzzyaZs?qCKu0o~C^ zA1IHIlO|I>WEIe&_C{`+Frp25CQP(FLTxQwPQ{%>x}me4ic^p!`tp9uckOEndR@E*Shp@FFo9^;nbJ8O2vHIr*tZP$4lSw z(s#V{9skz6L+LwyuYAWd6l1<}p9MOX#XieopZAWCmc>3x{-G@PSr+>&i+z@S;CI6Z z{>a5XvwAb!4Ab3nTz4*YTdJF?8%QZ9J5J(YJY9F6*sp@bIZvwBIDciL#UCrYw~)y% zDq<2DzhW^1-rQx~)mHx^u`cqf1lU@MxZ6>CXZER7f!~Q?tGJMMD$Sx9cZkaGMx!02 zQv1r{s7G9r^{Me-<-td6NJZ3Ux!6p;2J`B*vpq{4cd6sPx$;53R@c&ZPa2@qeV4lL zpH_A)b>H`fX((l$`>gxcsmG1<;IjrQb>gK?{N54RQYT*W6{SwR)QOim@sdCMZurBW zQzu^itc59Dhbh(i=V-~bw-Zx<2Z_>J%i@t?H`2=T+gD!e>nMXezH=Qo>aW#-zE|bx zPF@{ia}1Pj(Y&mGU{tKyzu4=|W7lk%-)M?>58)Xg1Jk5eFEeqYua z;PLzIHdQ3gjFoRky#*l1S!W3VKMDZAG93#(Qy_iFtez^ragr=Y#^j;4MFjq_7&w0M zog^J%D5O&!Ujqb_1Vk01w+QUAYa3P~W$Gb(8MVYd*+*U2HC1|>gP(%%0e%97c}iU> z4W#P(GJIDXyK3wxu%jC(RE~WU8MrR;wb587!;hm7IXoH=1!ztvL|O-s@$H~Ob!g=W z#V2rWZ(wWup%H=vo@fnGws$b7(J~{AIHo=+1bC#hwv}d|s$EwS1Q{ zgHkF&604)b{3@JyLFUPVQ#E5Z64?Rpd@3|O#5$Hik(eqeTwaDQy(W9kM_V?WD7>Y^ zStgYrHFQ9r0I|hZx$Pugq++}J5VwRB$8LWClqlMk2=S%}@dkm8CpN-DI4#0a;Z{Us z^I=nuTuP2RCzN9>Y@q7So(ro^96@4N;k80)Yg&qiS+iKR;gKZXC9PSi?wzMb5y{<9 zZE4A3;=Tx4cDfE4KI9yijYadqRNuvH-8{=B4f}AC142B3-L@T(wNRm3J&`n8ATT2f zMpcKKh9CkwVdY37{I=3lqJZi|Di)3@wkcJk4q-KJm5>!BF|5^9GN^$Yhgi^Dt_5qB zD}=`b2qY?dV+u~r(g54{kT*~mcJkM+HD6g(U#=QPL!(0ERs*m~ieey{p((QZez5~T zIqQH(Yu|}b*fBei@O~45N=i?Va-qW)u1UEQKP)7tuTy3G0InYHfw97u07(J0y(O$U z@pc!$p0S7I&$jkU-??v#LX%unX&jo)fmc~stt7ar0NQ2v<1X`I-Ro%basw-m+&S9Bhm1>F0Mr#uqp=|=e1sWq=;p8OKK>TyDEOui7_^jLG74KX_8#!@= zai+?m5`H#%Y?!*t2Qe5Ex1itb)|4SS(c9LV@gkZrlwP)^fIT6 z0|!PzmMCIiMiJZ{te@Q1TwPF3wM~*ZbyEZ%pD^nPyr!1X8c|kp*M}o_##Vz z$V-O-IhpoICXR!y>4^kjnvzLWKuH9mTY?dN8AX!a%5Vp09+^Scj+*l`OZpN^R_W#d zHhiSuU=IZ9H(_y%k!Rz2>d?ncuN!oCvd;ai{Pp^H81{UNjw|$@) zgHoeDhk>o-kgR~mbrX%K*ep#SDh56>`${7!RGT?BLf5U++~MfR>xFP76<;JcQcn8| zV-7cXO^C_TFR{2=RW$=UN*2ZDIp6iWK<>o?UyD|?OY(6_ClS zOl76e2O!~Lh@TiUG@{_8Meay9f$OFR&0l2!^I(b0zE@->ondqU6HHV=oYp|sNubkh zK6``TwxsYRE;jK^I;Wsp_yy=BHhKc0mIekhxQO`?-USEE zDUFfRMv^AU6Y+k52uhK4i1{x#|5SY9^^jQJ^6tWCDh_LwdFgmXqcK zixQ5dKh>Q$Pz?Rxq617s0&?O`h$}k=Yjc6}9KKoqand~4#?AeY)AQ}^kF{|xt*yq+ z;qp8o&vry|>pr?1PxXMzG?b9N4Zum>%k)AN{ccZps>15y>@<&{>7$wmxFT0DGeU*ScEj> z!NWV;E#o|LTdn4S1yp*s;(of5`fiCYZ`Jr&b-XFq z>-2o<#m5>?RKr<~gOjraW$nH``dFpqO-Ed4!Hk|eVdMu>W7{(A6hi_`MM(B5YC!f9 zLJ62NK$Hc>GU4q}+Z_|J4uBsaMLpa<=uI{{w4(+VW1RsS5#ZVgrK!kh5NPJ6PV8c= z8?!xJeVMHwoC~x~u)Yj** zikoPlbscT)M3j zqzh#Fw@-FZcFF|VSHJR>B$J+dQ#j*~Z5>Y~u&GwcNYz3v;ypV9OXDe@XBjEA6uU3 zUyXp%nd^(P$6TLHCxw{$y|Jcq2~O9G=bwvK+|*!w56rjY>;3rm-~TB8-Sd9~vweZi zYdI%gktgoOQ9w^fEmZvkq;9Tn)K}{D^@k6QI{!=mTzmZZ;lszq%ELzwAJ-o~s;{jX z_0`q&_0@kE^*dT~>R*hW+W)BE_*>b<-NMwq#=rkukS2vQ)(GZY1VAE$xbp7Tr#)(8|WfM(qk?Ha;x;{x8a>YP4v4 zXkP!YV6<)59bgQf*VUe2UV-FE7>!&Te&>#xMtyK){By64{{xo5b*$fjN${dd=zOs^ zjg^(bm3YuG`;I%>M1z85dggEVON1tdiur%odlRj;v29WGe2Omn+!LsP8`?nas(THX zU_xUW14$*LO15NML6(dpW6Z7^yXZ#k*?PzSd3Q?G=s~m+`hK$>cN)gp)0}je#4>JF&&Yq$0Gj(_zaxW>UA{VYe5@B5A-do zhYVcb>HLbK^7`tcb-8_VHY~k++i@%K@A$mYtu{LF+cy4LIo+we$Di#t#mz4MvfVp4 zJS&}6io@MvrT)5er<)h}eCK+*c6EIOzxnw0VRcxo^!h{UyuEUEaM5V&uXNn&?VX`f zzpd0uLwI2u2WQp2#>vs(_@)%p?7iyyZh!NkcvoHiynoZZzpj)X4t9>Lz-ye^x2v{u zZU@!doxSef+vTH!y>9KUw%5O@Ug;O7x1aT^4`(<1QfGO+arWWnqTkf@lY^Vx+naL9 ze{fHB>&4>6b?vBOmQ1_VcIw9FgWom3{o#JYJi9C(u9Oen-oI~d2cO@)+gxego44)J>3-Af-h5~_ zZr&}II&Sr_I;cHVjn~Hh`Rm(<;`=xCi*u`8+H>CCG_7H)@_AoxlwY4-o%Dk}YqWFN zFCIGY`a%D;Hn`ruU-z14%j-7>t?lcT(P3x1?HC(7_rrr;+wRwwhmB5YzgBS@jsA!2 z-n)nD;oGa$@f)Y&t-tLLHiE-Oapz`O(cO0!S7+y^qu|Zv>6_vD!E5*O^yZzj?KbWG zjmuKKe%Cm;+kJoCcKg-h`9ZI8=-)e+z4gQ9jSJfz9J|9Kv*ulT+pX=se$+G0OP@E5 z_J-c^Im6g{g)!oZM^X+P*`cPlLFYldPIp%(M`Es|izg-F5lxj`mdbefz z$L`LZzfxVR?2X`#t~pI_xBj8uYE(~8FZMoI)ywhUsdeGf78-u;WZmoWER$t#fJ*;hey(7~ZeC`!jI;#(@ zmCZN$ux4(SJ~Y=V<-46QKV^>J&kcGnpWZM|`P|9WL(bJJ^^ zo9lzkccp{#-RsuL!^y=#XLWnM*6Cfh{ZXg2QQW@kY<4!QYj29J-OJwF+V)Ak_;B%N z`=R`Bdv)7yUe|o1bh6piKVP>G*9WWH`pNnFxp_sqZq5g%y`3EZe%%fbtlgHY zva@5T*mAn)^YL-6_&Y=Lh@&{JF9u#BpE?;|C$qe1RDjr2<6F{{Us=~BN-VaeIquNY zqw6KKxTOs|dp>B|e>Ph;J{!5`ZAtzkN;{6G}M@BvJfUc%aJUvV_)>z~GiSe!PQtFw# z#AU_!5f=sE0Hn%NVGUlE1S*)+s8Gn#>X#6!ywDShabF1;A!3>lrpfPg(vHx|WdXNCBzetC0)+qBmgD5h|a-j`?nndt#S(RH`Q|;F}TM=jGIS-zA)VOItzc6OTxF%%4rW zMu52@rZOdfmtuLErii{Z3HiNzp{-OX{PKdAH^1W|i+aECg4Y-cddWr=0iq>MoGLaa zA)hp;7DDwCBMX9c`m$VD%|@t8KMkQ4$^Sc6`#EIPs;~`yO(Gq}b-`(ce@rBfkv=b8 zB>hq8V=f}UND&!Lo+aI! zfIf+s`%TCv^E`!fV!uozo3d>#Q2r1C_G{Fmw<0$21+W*xW#ylk7BqA@273T$b60U$O46lCIyxkEp=21HcjT-zJjt zuyrm1{~RLl%ckkiA_7k)-(huDp^cT$j36F-0( z{u(4%0+C9Tt1A4xWHa?tqAWizIR~!9QQt!oKz!9WM(vSSvIGkKeH^$0s>Gj z<-bj&t6}S0iv1y^*fLPWT>8wU5LVK=Mzq4pa~w|lTbsNhnM(Acu~H}(%83rfX-Q?p z%#Cwx2a9N_v!B6xji)yJuBG9tuB-7trI)Oh&PC$yg`EG0BC&4Urq>zOoB+Vy<9WKq zA5J9aiDTpf^UonL|AfBNU(6>wO5)~FW`$5D#;+uT>vwEiKI8@YsOT9pm%qZa0BFK)<)o{OVlF@yTQ37znDnRQ^(4s^&di7|BmVT z3w7>})ytxA;>eTl3UFe_NJs%FiP-`?(9(JwA1Ik$MY>#~&{Ud~R~QA@=va5AlgrdO zCH{G2YV)4T?(P9JD8w`2>6A1bNu%UqbuLzadouiD?a*JF5P#)c@dcz9OSFG0a-0jr zKPD-D=|ZvV={BZwC9^d9b&6eDw$DY~T-5#H+^e60!fwMto33L@fc`!9ry1mD|3@Nf#eJ~}wH`X^7LUSQB7eZeE75%C=qRP;K z^i-ZsNa-tKsrVMgTT7djGHvD6HS_A4d3DXay5={luBq+1tpOrwRzNt0E<(&rMK_Ct zYT&0~-TglI&*Ge{UNN`z$A9L4b)MTwBpOMH@qO_ugvz24m79M!L>0pjhL-rQFC zpT(ybde{*oqY)AYYxm}!W@N0WZRnoiFR{d{v3Y>21S&W1O?dvN^5Q>FzI(vL)3E`O z0M^*E?o4CPbLEci=#J|k$xWFiR?MK*$Yz$ zA|b>vmP^YkOQp4?mC{AIytTTqwFZA~Y?VsTuRmzomfyu2Ik$hfq2c}X9Gyoj6}3If zfsKcw){b-sC@RhX-QY|Dn43;G0M;4!2m0L~`GE<)(*E%S3iok192Uf0o}+u0r;YfS%_mwX`DI65`^u}@w zToONB=%Y9BCgAPlaDZHW5|ANh7=rB#XliUE!b>_ICA^9Vs*c|GyKX?3kzpQw&`B%m z>p?f^IgH>zzquI;htkTF@2iLx0tW1nrpJO7+op;4Tbt*qf(4ugt=4ICVPLojp#U!4 zr&BJ_?eDSA4Wq~nEaawhM5dkb#mtLaynx+6P|^Xn{V_L=_we&0f54ApWmTbS5MBrw zM>d#)+tRaOA4&*w_pq*C_$9!@N!$8(kAD``1IDAgG4VzlZf^+z$T#nh3&BT}j2Iqu z2SB~x?-~m?k%$QJ8X-~(^Z#3pjIS3B7Bwr-&i5;ahuZPUg=nielnz2h;rk5zLnmL} zKQ?9|{LSq3gVFo~G`;5^3Ukf(bvTt^YpcB#t^<*&@;7$jkd+-icHJIy77HZU4>xJ$ z**2+(-QcEqteip{xCG4v3^0WDCiaaC4A+`aIwD4)+>EUQ~DjeyLMm1gi&V=qgGtdj1X{{OfvHY2>`$40R{H2i&9ns$6JO#I= zHd2P0a_q6Q#@_HlPO`v;pWZ{6@fnV0?fH+!H(LW7UbFS4X-_!>(fCoDD>^^fK7EOR z$XO^wJZu6rWVt5@Qxni_yRL2GR>KJ9?Bej`NtR%zZJo(TV!|WcyD{7$Zv9o)bkyVR z>OR~@&CxwiM`3Ld#1mk(pCjs*EgdZX1K|FCOa?ET4C#Tk_?g2L}F(N=^slk^W;n8u|Yr+cQ+VLp)@%2=>J%S|AEK&H&u3Gvb5o(rfM5nbBir}_ z>n}51bJ(b$ogk_Zb>0@)gp5IT2%)q9m3B-&Dl+5D4EwR<^lxT{DDuk~(wlTHI4Hgk053cZP4yq+7$!5%-OsYl6B3c(ge6h_l%h8}R;q z?M(P~_{_P3GtgW{bP@h6+W?s@P8NW3qhe4D@z0B9Al*{>Yk2%Sz@7L*B>`;gzuQ~C z{7r=OG02>I8GPYIMTQ3$j^J!ylcFdEJ;qvzt6KqlL0M8fe8HG4bW~{ZOyA-bVV+Wk zN{l3xqnbi8IwIZQA!=vZt_`FEuN>mA2zN@O(;a|KLJW$m9b1L)*5Pa*2BcB{zUQ{^ z90AEGw;fP1GG!bEBifHG&IuVr#weqA0>{4eRlNF$&x{xRBXrw^c?eXP z>$`BmP2@)+Y&{HFME;FOFtURj!ga@&oL$!MT%ec(U!H{U5)?OtZV{69MM>Bne_#Bu z8{^YS;r3gYb|_@rQ}5CuXBptY;dg$qW+2ru2+x3j(K?f(-#Ge3WUD?oGj%^SQwlFa z_?7fBCYM2F6m8RN>2R$WiRNd*F9nLDaN2ixgV589+cdC_A&djyG0qUMo5lzC*{@^@?}^DpJwS`7LDRBPV>S;r9XWb?v`GB0g~|q!-{GuK+NE*& zrU_5PS;})U4+g?(Z#@C z3oH?8cYKaLkvxzIB#z-jcJi3`K088mP+!Fe4{shhCAM&hR^&>e5#Yc<4ti|eIvE+` z->>O|!0qV)JBETr8c|z0Qeo#eNc+i0w{W#Stxdic`vzbhLLYlX2Q=@SCI>Khynk_Y zxJDwH#hgkAYpZ}|vpwuueV8T~nm`8bd-fKtO%qxsdGeYbFx{28FdI5n04M_{rXM*f z&aBU90`QYQI*-0Ekj};3Y${x5EFoQvX$I52P5G`gnu2&Wm7egIf|gt$4))ohj_X_ydf8IP z=vz$kpjJy#2_9k}BAZ-^h~znCE~f8>i4;66HR%zg8SfZs=NZBbr*=$}p-C*Fgp{76 z-%#B_xO!0otb&M;J0{WGLhb1f+Q8VNzXfoa@WjTKsKZAL`EjmgTMk34WE%K5$2I$G z)1c{R1b*C>I8i#2`lT=gg$NO2W`REQeZ#54{9AHQLQR;m*Didj43EBB;NBwN5kFcFQD zdB;Q!1@9o96%9(X+crCTE3_5~Plk|op%kw7*~KAN^CRi?wWjSh;pAP9_o0U#R4?MJBX=3e4$w!W0sJSoUyZxpBv8nxwLl<- z3^ejs&Yu&?r&Rn`S^=HH|Cyk8mynl=;MEoa%S(s{7SFK}y{H~dabdwXQxF5u0mJN@ z4t@YrQ;>kJlZY)j;HbZE;r�_%U3yjS90os6(UuwLF zMbCAnIY=R;78C3zZsr#GmzmL_1u9UdDn3u!S6L8nCG?eAF;7R+vbZ1s|ou$uiGv<`>JnwB=~Xmg48|x)yTf zBt)UL6z&P`g@EI#GF_Abb2RnctVdx^=$mj9;Z!*R2#xF*c{t^hkK_k(bGqVL58pt` z`s4o7A`|XiSZwT@W&6-j{=a@>{U+MSWxs|0^_zV7%O8FKc-vn>_G{jDF_sdYz~>7K zi@@8^s2}GRK0V4n9=#(O;B_hxZtd_0f~8u>CuN3A^O(7}rwkHON*<+vuj5cw10fJl zzUha-3<}FOu9kR4z=O|bbO15;BV%Xq!io=BQa)tv69;R9fA}99oO59RL4o}h4f^WK zL0#?-+_@#Vdbd;d=oI}-pr@mQ;7uG&pKepxLYRy=CsxK#6~Qb*s+(3J-}MdwdnD7d9Cb01?b5420g-;nLJz%mg>R z1AOH?0|_{A@d?wNLcav&$B%A$gar*`k~<6mJ#3&wusH z01#eDy_3BEyYQ9$f!B8dg`jx(ik(NjlKB{vDI&WR&NUUchy`Rg#l&7wlmu)4%CIxV z&p+IXCD<%W+~&1%!`5CO5qPE4S#a5JCqSi;@@Ep4CUEB{K^2_<&eqH|`%9k;em zVatV?y*gD!PHH(O&~gQ{q892nt~c)EbQCq!h9&|H@Lw8zq^L9)-neZet1 z>`P%HWO}4ls-RtPO9gjC!dVp-6fFbvmCS2hC-0tsDZR;1!(s-w*HZ`Y5XPqqOS^P) zRrU}^8=DBNGVY+$g|{Qsdx&P?+?;e*LKUOz8eIs<-afIliL;V0 zs8#SgTHKDWc(Ey_3S>JYR-vxhx*@Yccp%}2esn%MN8``cuu|rH6_D>K_Z?}|#>jJp zrApHf%oCLtwRuYho^Vh}9&!xk;5z!I-Ch{;d`4pt(|9^AoVnHM>0@50$rp&ii%0A_ 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�*hP%}sd+ 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≻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 literal 0 HcmV?d00001 diff --git a/lib/davinci_crd_test_kit/jwks.rb b/lib/davinci_crd_test_kit/jwks.rb new file mode 100644 index 0000000..1990ca3 --- /dev/null +++ b/lib/davinci_crd_test_kit/jwks.rb @@ -0,0 +1,25 @@ +module DaVinciCRDTestKit + class JWKS + class << self + def jwks_json + @jwks_json ||= + JSON.pretty_generate( + { keys: jwks.export[:keys].select { |key| key[:key_ops]&.include?('verify') } } + ) + end + + def default_jwks_path + @default_jwks_path ||= File.join(__dir__, 'crd_jwks.json') + end + + def jwks_path + @jwks_path ||= + ENV.fetch('CRD_JWKS_PATH', default_jwks_path) + end + + def jwks + @jwks ||= JWT::JWK::Set.new(JSON.parse(File.read(jwks_path))) + end + end + end +end diff --git a/lib/davinci_crd_test_kit/jwt_helper.rb b/lib/davinci_crd_test_kit/jwt_helper.rb new file mode 100644 index 0000000..13cb47f --- /dev/null +++ b/lib/davinci_crd_test_kit/jwt_helper.rb @@ -0,0 +1,74 @@ +require_relative 'jwks' + +module DaVinciCRDTestKit + class JwtHelper + def self.build(...) + new(...).signed_jwt + end + + def self.decode_jwt(token, jwks_hash, kid = nil) + jwks = JWT::JWK::Set.new(jwks_hash) + jwks.filter! { |key| key[:use] == 'sig' } + algorithms = jwks.map { |key| key[:alg] }.compact.uniq + begin + JWT.decode(token, kid, true, algorithms:, jwks:) + rescue StandardError => e + raise Inferno::Exceptions::AssertionException, e.message + end + end + + attr_reader :aud, :encryption_method, :exp, :iat, :iss, :jku, :jti, :kid + + def initialize( + aud:, + encryption_method:, + iss:, + jku:, + iat: Time.now.to_i, + exp: 5.minutes.from_now.to_i, + jti: SecureRandom.hex(32), + kid: nil + ) + @aud = aud + @encryption_method = encryption_method + @iss = iss + @jku = jku + @iat = iat + @exp = exp + @jti = jti + @kid = kid + end + + def private_key + @private_key ||= JWKS.jwks + .select { |key| key[:key_ops]&.include?('sign') } + .select { |key| key[:alg] == encryption_method } + .find { |key| !kid || key[:kid] == kid } + end + + def signing_key + if private_key.nil? + raise Inferno::Exceptions::AssertionException, + "No signing key found for inputs: encryption method = '#{encryption_method}' and kid = '#{kid}'" + end + + @private_key.signing_key + end + + def jwt_header + { alg: encryption_method, typ: 'JWT', kid: key_id, jku: } + end + + def jwt_payload + { iss:, aud:, exp:, iat:, jti: } + end + + def key_id + @private_key['kid'] + end + + def signed_jwt + @signed_jwt ||= JWT.encode jwt_payload, signing_key, encryption_method, jwt_header + end + end +end diff --git a/lib/davinci_crd_test_kit/mock_service_response.rb b/lib/davinci_crd_test_kit/mock_service_response.rb new file mode 100644 index 0000000..4613ef2 --- /dev/null +++ b/lib/davinci_crd_test_kit/mock_service_response.rb @@ -0,0 +1,421 @@ +module DaVinciCRDTestKit + # Serve responses to CRD hook invocations + module MockServiceResponse + def current_time + Time.now.utc + end + + def coverage_information_required_hooks + ['appointment-book', 'order-dispatch', 'order-sign'] + end + + def get_card_json(filename) + json = JSON.parse(File.read(File.join(__dir__, 'card_responses', filename))) + return json unless filename == 'launch_smart_app.json' + + json['links'].first['url'] = "#{Inferno::Application['base_url']}/custom/smart/launch" + + json + end + + def format_missing_response_types(missing_response_types) + missing_response_types + .map do |response_type| + response_type_string = + response_type.split('_') + .map(&:capitalize) + .join(' ') + .sub('Smart', 'SMART') + .sub('Create Update', 'Create/Update') + .sub('Companions Prerequisites', 'Companions/Prerequisites') + response_type_string + end + end + + def missing_response_type_filter(response_type, hook_card_response) + if response_type == 'coverage information' + hook_card_response['systemActions'].nil? || + hook_card_response['systemActions'].none? do |card| + card['description'].include?('coverage information') + end + else + hook_card_response['cards'].present? && + hook_card_response['cards'].none? { |card| card['summary'].downcase.include?(response_type) } + end + end + + def get_missing_response_types(selected_response_types, hook_card_response, hook_name) + if coverage_information_required_hooks.include?(hook_name) + selected_response_types.append('coverage_information').uniq! + end + + selected_response_types + .select do |response_type| + response_type = response_type + .split('_') + .join(' ') + .sub('create update', 'create/update') + .sub('companions prerequisites', 'companions/prerequisites') + missing_response_type_filter(response_type, hook_card_response) + end + end + + def create_warning_messages(selected_response_types, hook_card_response, hook_name) + missing_response_types = if hook_card_response.nil? + selected_response_types + else + get_missing_response_types(selected_response_types, hook_card_response, hook_name) + end + + return if missing_response_types.empty? + + missing_response_types = format_missing_response_types(missing_response_types) + missing_response_types.each do |missing_response_type| + Inferno::Repositories::Messages.new.create(result_id: result.id, type: 'warning', + message: %(Unable to return response type: `#{missing_response_type}` + for #{hook_name} hook)) + end + end + + def create_card_response(hook_card_response) + if hook_card_response.nil? + response.headers.merge!({ 'Access-Control-Allow-Origin' => '*' }) + response.status = 400 + response.body = 'Invalid Request: Incorrect format for hook request body' + else + response.body = hook_card_response.to_json + response.headers.merge!({ 'Content-Type' => 'application/json', 'Access-Control-Allow-Origin' => '*' }) + response.status = 200 + response.format = :json + end + end + + def update_specific_hook_card_info(card_response, hook_name) + return if card_response.nil? + + hook_display = hook_name.split('-').map(&:capitalize).join(' ') + card_response['cards'].map do |card| + card['summary'].prepend("#{hook_display} ") + card['uuid'] = SecureRandom.uuid + end + card_response + end + + def appointment_book_response(selected_response_types) + cards_response = create_cards_and_system_actions(selected_response_types, 'appointment-book', 'appointments') + hook_card_response = update_specific_hook_card_info(cards_response, 'appointment-book') + create_warning_messages(selected_response_types, hook_card_response, 'appointment-book') + create_card_response(hook_card_response) + end + + def encounter_start_response(selected_response_types) + cards_response = create_cards_and_system_actions(selected_response_types, 'encounter-start', 'encounterId', + 'Encounter') + hook_card_response = update_specific_hook_card_info(cards_response, 'encounter-start') + create_warning_messages(selected_response_types, hook_card_response, 'encounter-start') + create_card_response(hook_card_response) + end + + def encounter_discharge_response(selected_response_types) + cards_response = create_cards_and_system_actions(selected_response_types, 'encounter-discharge', 'encounterId', + 'Encounter') + hook_card_response = update_specific_hook_card_info(cards_response, 'encounter-discharge') + create_warning_messages(selected_response_types, hook_card_response, 'encounter-discharge') + create_card_response(hook_card_response) + end + + def order_dispatch_response(selected_response_types) + cards_response = create_cards_and_system_actions(selected_response_types, 'order-dispatch', 'order') + hook_card_response = update_specific_hook_card_info(cards_response, 'order-dispatch') + create_warning_messages(selected_response_types, hook_card_response, 'order-dispatch') + create_card_response(hook_card_response) + end + + def order_select_response(selected_response_types) + cards_response = create_cards_and_system_actions(selected_response_types, 'order-select', 'draftOrders') + hook_card_response = update_specific_hook_card_info(cards_response, 'order-select') + create_warning_messages(selected_response_types, hook_card_response, 'order-select') + create_card_response(hook_card_response) + end + + def order_sign_response(selected_response_types) + cards_response = create_cards_and_system_actions(selected_response_types, 'order-sign', 'draftOrders') + hook_card_response = update_specific_hook_card_info(cards_response, 'order-sign') + create_warning_messages(selected_response_types, hook_card_response, 'order-sign') + create_card_response(hook_card_response) + end + + def make_resource_request(uri, access_token) + response = Faraday.get(uri, nil, { 'Authorization' => "Bearer #{access_token}" }) + return unless response.status == 200 + + resource = FHIR.from_contents(response.body) + return resource unless resource.resourceType == 'Bundle' + return if resource.entry.empty? + + resource.entry.first.resource + end + + def get_patient_coverage(request_body) + prefetch = request_body['prefetch'] + if prefetch.present? && prefetch['coverage'] + FHIR.from_contents(prefetch['coverage'].to_json) + else + fhir_server = request_body['fhirServer'] + if fhir_server.present? + access_token = request_body['fhirAuthorization']['access_token'] if request_body['fhirAuthorization'] + patient_id = request_body['context']['patientId'] + + make_resource_request( + "#{fhir_server}/Coverage?patient=#{patient_id}&status=active", + access_token + ) + end + end + end + + def get_context_resource(request_body, resource_type, update_resource_id) + update_resource_id = "#{resource_type}/#{update_resource_id}" unless update_resource_id.include? '/' + fhir_server = request_body['fhirServer'] + return unless fhir_server.present? + + access_token = request_body['fhirAuthorization']['access_token'] if request_body['fhirAuthorization'] + make_resource_request( + "#{fhir_server}/#{update_resource_id}", + access_token + ) + end + + def add_coverage_cards?(selected_response_types, hook_name) + (['coverage_information', 'create_update_coverage_info'].any? { |x| selected_response_types.include?(x) }) || + coverage_information_required_hooks.include?(hook_name) + end + + def create_cards_and_system_actions(selected_response_types, hook_name, update_resource_name, resource_type = nil) + request_body = JSON.parse(request.params.to_json) + context = request_body['context'] + return if context.nil? + + cards = [] + + add_basic_cards(selected_response_types, cards, context) + + add_order_hook_cards(selected_response_types, cards, request_body, hook_name) + + system_actions = add_coverage_cards(selected_response_types, cards, request_body, hook_name, + update_resource_name, resource_type) + + cards.append(get_card_json('instructions.json')) if selected_response_types.include?('instructions') || + (cards.empty? && system_actions.nil?) + cards_response = { 'cards' => cards } + cards_response['systemActions'] = system_actions if system_actions.present? + cards_response + rescue StandardError + nil + end + + def add_order_hook_cards(selected_response_types, cards, request_body, hook_name) + if selected_response_types.include?('companions_prerequisites') + cards.append(create_companions_prerequisites_card(request_body['context'])) + end + + return unless selected_response_types.include?('propose_alternate_request') + + cards.append(create_alternate_request_card(request_body, hook_name)) + end + + def add_basic_cards(selected_response_types, cards, context) + cards.append(create_form_completion_card(context)) if selected_response_types.include?('request_form_completion') + cards.append(get_card_json('launch_smart_app.json')) if selected_response_types.include?('launch_smart_app') + cards.append(get_card_json('external_reference.json')) if selected_response_types.include?('external_reference') + end + + def add_coverage_cards(selected_response_types, cards, request_body, hook_name, update_resource_name, + resource_type = nil) + return unless add_coverage_cards?(selected_response_types, hook_name) + + coverage = get_patient_coverage(request_body) + if coverage.present? + if selected_response_types.include?('coverage_information') || + coverage_information_required_hooks.include?(hook_name) + system_actions = create_coverage_extension_system_actions(request_body, update_resource_name, + coverage.id, resource_type) + end + + if selected_response_types.include?('create_update_coverage_info') + cards.append(create_or_update_coverage(coverage, request_body['context'])) + end + end + system_actions + end + + def create_coverage_extension_system_actions(request_body, update_resource_name, coverage_id, resource_type = nil) + context = request_body['context'] + update_resource = context[update_resource_name] + prefetch_id = update_resource_name.split(/(?=[A-Z])/).first + + fhir_resource = if update_resource.is_a? Hash + FHIR.from_contents(update_resource.to_json) + elsif request_body['prefetch'] && request_body['prefetch'][prefetch_id] + FHIR.from_contents(request_body['prefetch'][prefetch_id].to_json) + else + get_context_resource(request_body, resource_type, update_resource) + end + create_system_actions(fhir_resource, coverage_id) + rescue StandardError + nil + end + + def create_system_actions(resource, coverage_id) + return if resource.nil? + + system_actions = [] + if resource.resourceType == 'Bundle' + resource.entry.each do |entry| + entry_resource = entry.resource + add_coverage_extension(entry_resource, coverage_id) + system_actions.append({ 'type' => 'update', + 'description' => + "Added coverage information to #{entry_resource.resourceType} resource.", + 'resource' => entry_resource }) + end + else + add_coverage_extension(resource, coverage_id) + system_actions.append({ 'type' => 'update', + 'description' => "Added coverage information to #{resource.resourceType} resource.", + 'resource' => resource }) + end + system_actions + end + + def add_coverage_extension(resource, coverage_id) + resource.extension = [ + FHIR::Extension.new( + url: 'http://hl7.org/fhir/us/davinci-crd/StructureDefinition/ext-coverage-information', + extension: [ + FHIR::Extension.new( + url: 'coverage', + valueReference: FHIR::Reference.new( + reference: "Coverage/#{coverage_id}" + ) + ), + FHIR::Extension.new( + url: 'covered', + valueCode: 'conditional' + ), + FHIR::Extension.new( + url: 'date', + valueDate: current_time.strftime('%Y-%m-%d') + ), + FHIR::Extension.new( + url: 'coverage-assertion-id', + valueString: SecureRandom.hex(32) + ) + ] + ) + ] + end + + def create_coverage_resource(patient_id) + FHIR::Coverage.new( + id: SecureRandom.uuid, + status: 'draft', + beneficiary: FHIR::Reference.new( + reference: "Patient/#{patient_id}" + ), + subscriber: FHIR::Reference.new( + reference: "Patient/#{patient_id}" + ), + relationship: FHIR::CodeableConcept.new( + coding: [ + FHIR::Coding.new( + system: 'http://terminology.hl7.org/CodeSystem/subscriber-relationship', + code: 'self' + ) + ] + ), + payor: FHIR::Reference.new( + reference: "Patient/#{patient_id}" + ) + ) + end + + def create_or_update_coverage(coverage, context) + return if context.nil? + + if coverage.present? + action = { 'type' => 'update', 'description' => 'Update current coverage record' } + coverage.period = FHIR::Period.new(start: current_time.strftime('%Y-%m-%d'), + end: (current_time + 1.month).strftime('%Y-%m-%d')) + action['resource'] = coverage + else + action = { 'type' => 'create', 'description' => 'Create coverage record' } + new_coverage = create_coverage_resource(context['patientId']) + action['resource'] = new_coverage + end + coverage_info_card = get_card_json('create_update_coverage_information.json') + coverage_info_card['suggestions'][0]['actions'] = [action] + coverage_info_card + end + + def create_form_completion_card(context) + return if context.nil? + + request_form_completion_card = get_card_json('request_form_completion.json') + form_completion_task = request_form_completion_card['suggestions'][0]['actions'].find do |action| + action['resource']['resourceType'] == 'Task' + end['resource'] + + form_completion_task['for']['reference'] = "Patient/#{context['patientId']}" + form_completion_task['authoredOn'] = current_time.strftime('%Y-%m-%d') + request_form_completion_card + end + + def update_service_request(service_request, context) + return if context.nil? + + service_request['subject']['reference'] = "Patient/#{context['patientId']}" + service_request['requester']['reference'] = context['userId'] + service_request['authoredOn'] = current_time.strftime('%Y-%m-%d') + end + + def create_companions_prerequisites_card(context) + return if context.nil? + + companions_prerequisites_card = get_card_json('companions_prerequisites.json') + card_service_request = companions_prerequisites_card['suggestions'][0]['actions'][0]['resource'] + update_service_request(card_service_request, context) + companions_prerequisites_card + end + + def create_alternate_request_card(request_body, hook_name) + context = request_body['context'] + return if context.nil? + + propose_alternate_request_card = get_card_json('propose_alternate_request.json') + + if hook_name == 'order-dispatch' + order_resource = get_context_resource(request_body, nil, context['order']) + else + draft_orders = context['draftOrders']['entry'] + draft_order_resource = draft_orders[0]['resource'] + order_resource = FHIR.from_contents(draft_order_resource.to_json) + end + return if order_resource.nil? + + order_resource_type = order_resource.resourceType + order_resource_id = order_resource.id + + card_actions = propose_alternate_request_card['suggestions'][0]['actions'] + card_actions.append( + { + 'type' => 'delete', + 'description' => 'Remove current order until health assessment has been done', + 'resourceId' => ["#{order_resource_type}/#{order_resource_id}"] + } + ) + update_service_request(card_actions[0]['resource'], context) + propose_alternate_request_card + end + end +end diff --git a/lib/davinci_crd_test_kit/routes/cds-services.json b/lib/davinci_crd_test_kit/routes/cds-services.json new file mode 100644 index 0000000..5156d0e --- /dev/null +++ b/lib/davinci_crd_test_kit/routes/cds-services.json @@ -0,0 +1,74 @@ + +{ + "services": [ + { + "hook": "appointment-book", + "title": "Appointment Booking CDS Service", + "description": "An example of a CDS Service that is invoked when user of a CRD Client books a future appointment for a patient", + "id": "appointment-book-service", + "prefetch": { + "user": "{{context.userId}}", + "patient": "Patient/{{context.patientId}}", + "coverage": "Coverage?patient={{context.patientId}}&status=active" + } + }, + { + "hook": "encounter-start", + "title": "Encounter Start CDS Service", + "description": "An example of a CDS Service that is invoked when the user is initiating a new encounter.", + "id": "encounter-start-service", + "prefetch": { + "user": "{{context.userId}}", + "patient": "Patient/{{context.patientId}}", + "encounter": "Encounter/{{context.encounterId}}", + "coverage": "Coverage?patient={{context.patientId}}&status=active" + } + }, + { + "hook": "encounter-discharge", + "title": "Encounter Disharge CDS Service Example", + "description": "An example of a CDS Service that is invoked when the user is performing the discharge process for an encounter - typically an inpatient encounter.", + "id": "encounter-discharge-service", + "prefetch": { + "user": "{{context.userId}}", + "patient": "Patient/{{context.patientId}}", + "encounter": "Encounter/{{context.encounterId}}", + "coverage": "Coverage?patient={{context.patientId}}&status=active" + } + }, + { + "hook": "order-dispatch", + "title": "Order Dispatch CDS Service Example", + "description": "An example of a CDS Service that fires when a practitioner is selecting a candidate performer for a pre-existing order that was not tied to a specific performer", + "id": "order-dispatch-service", + "prefetch": { + "patient": "Patient/{{context.patientId}}", + "performer": "{{context.performer}}", + "order": "{{context.order}}", + "coverage": "Coverage?patient={{context.patientId}}&status=active" + } + }, + { + "hook": "order-select", + "title": "Order Select CDS Service", + "description": "An example of a CDS Service that fires when a clinician selects one or more orders to place for a patient", + "id": "order-select-service", + "prefetch": { + "user": "{{context.userId}}", + "patient": "Patient/{{context.patientId}}", + "coverage": "Coverage?patient={{context.patientId}}&status=active" + } + }, + { + "hook": "order-sign", + "title": "Order Sign CDS Service", + "description": "An example of a CDS Service that fires when a clinician is ready to sign one or more orders for a patient", + "id": "order-sign-service", + "prefetch": { + "user": "{{context.userId}}", + "patient": "Patient/{{context.patientId}}", + "coverage": "Coverage?patient={{context.patientId}}&status=active" + } + } + ] +} \ No newline at end of file diff --git a/lib/davinci_crd_test_kit/routes/cds_services_discovery_handler.rb b/lib/davinci_crd_test_kit/routes/cds_services_discovery_handler.rb new file mode 100644 index 0000000..a376e7f --- /dev/null +++ b/lib/davinci_crd_test_kit/routes/cds_services_discovery_handler.rb @@ -0,0 +1,18 @@ +module DaVinciCRDTestKit + module Routes + class CDSServicesDiscoveryHandler + def self.call(...) + new.call(...) + end + + def self.cds_services + @cds_services ||= File.read(File.join(__dir__, 'cds-services.json')) + end + + def call(_env) + # Check authorization header + [200, { 'Content-Type' => 'application/json' }, [self.class.cds_services]] + end + end + end +end diff --git a/lib/davinci_crd_test_kit/routes/hook_request_endpoint.rb b/lib/davinci_crd_test_kit/routes/hook_request_endpoint.rb new file mode 100644 index 0000000..ab1fc7d --- /dev/null +++ b/lib/davinci_crd_test_kit/routes/hook_request_endpoint.rb @@ -0,0 +1,93 @@ +require_relative '../mock_service_response' +require_relative '../tags' +module DaVinciCRDTestKit + class HookRequestEndpoint < Inferno::DSL::SuiteEndpoint + include DaVinciCRDTestKit::MockServiceResponse + + def selected_response_types + inputs = JSON.parse(result.input_json) + selected_response_types_input = inputs.detect { |input| input['name'].include?('selected_response_types') } + selected_response_types_input['value'] + end + + def test_run_identifier + extract_iss_claim_and_hook(request) + end + + def extract_iss_claim_and_hook(request) + hook = extract_hook_name(request).to_s + iss = extract_iss_claim_from_token(request).to_s + + "#{hook} #{iss}" + end + + def extract_iss_claim_from_token(request) + token = extract_bearer_token(request) + begin + payload, = JWT.decode(token, nil, false) + payload['iss'] + rescue JWT::DecodeError + nil + end + end + + # Header expected to be a bearer token of the form "Bearer " + def extract_bearer_token(request) + request.headers['authorization']&.delete_prefix('Bearer ') + end + + def extract_hook_name(request) + request.params[:hook] + end + + def make_response + hook_name = extract_hook_name(request) + case hook_name + when 'appointment-book' + appointment_book_response(selected_response_types) + when 'encounter-start' + encounter_start_response(selected_response_types) + when 'encounter-discharge' + encounter_discharge_response(selected_response_types) + when 'order-select' + order_select_response(selected_response_types) + when 'order-sign' + order_sign_response(selected_response_types) + when 'order-dispatch' + order_dispatch_response(selected_response_types) + else + response.status = 400 + response.body = 'Invalid Request: Request did not contain a valid hook in the `hook` field.' + end + end + + def tags + hook_name = extract_hook_name(request) + case hook_name + when 'appointment-book' + [APPOINTMENT_BOOK_TAG] + when 'encounter-start' + [ENCOUNTER_START_TAG] + when 'encounter-discharge' + [ENCOUNTER_DISCHARGE_TAG] + when 'order-select' + [ORDER_SELECT_TAG] + when 'order-sign' + [ORDER_SIGN_TAG] + when 'order-dispatch' + [ORDER_DISPATCH_TAG] + else + response.status = 400 + response.body = 'Invalid Request: Request did not contain a valid hook in the `hook` field.' + end + end + + def name + extract_hook_name(request).gsub('-', '_') + end + + def update_result + results_repo.update(result.id, result: 'pass') + end + end +end diff --git a/lib/davinci_crd_test_kit/routes/jwk_set_endpoint_handler.rb b/lib/davinci_crd_test_kit/routes/jwk_set_endpoint_handler.rb new file mode 100644 index 0000000..44ad9de --- /dev/null +++ b/lib/davinci_crd_test_kit/routes/jwk_set_endpoint_handler.rb @@ -0,0 +1,15 @@ +require_relative '../jwks' + +module DaVinciCRDTestKit + module Routes + class JWKSetEndpointHandler + def self.call(...) + new.call(...) + end + + def call(_env) + [200, { 'Content-Type' => 'application/json' }, [JWKS.jwks_json]] + end + end + end +end diff --git a/lib/davinci_crd_test_kit/server_appointment_book_group.rb b/lib/davinci_crd_test_kit/server_appointment_book_group.rb new file mode 100644 index 0000000..188e22c --- /dev/null +++ b/lib/davinci_crd_test_kit/server_appointment_book_group.rb @@ -0,0 +1,173 @@ +require_relative 'server_tests/service_call_test' +require_relative 'server_tests/service_response_validation_test' +require_relative 'server_tests/card_optional_fields_validation_test' +require_relative 'server_tests/external_reference_card_validation_test' +require_relative 'server_tests/coverage_information_system_action_received_test' +require_relative 'server_tests/coverage_information_system_action_validation_test' +require_relative 'server_tests/instructions_card_received_test' +require_relative 'server_tests/service_request_required_fields_validation_test' +require_relative 'server_tests/service_request_optional_fields_validation_test' +require_relative 'server_tests/service_request_context_validation_test' +require_relative 'server_tests/form_completion_response_validation_test' +require_relative 'server_tests/launch_smart_app_card_validation_test' +require_relative 'server_tests/create_or_update_coverage_info_response_validation_test' + +module DaVinciCRDTestKit + class ServerAppointmentBookGroup < Inferno::TestGroup + title 'appointment-book' + id :crd_server_appointment_book + description %( + This group of tests invokes the appointment-book hook and ensures that + the user-provided requests are valid as per the requirements described + in the [CRD IG section on appointment-book hook](https://hl7.org/fhir/us/davinci-crd/STU2/hooks.html#appointment-book) + and the [CDS Hooks specification section on appointment-book context](https://cds-hooks.hl7.org/hooks/appointment-book/2023SepSTU1Ballot/appointment-book/). + It also ensures that the contents of the server's response are valid as per the requirements described in + the [CRD IG section on appointment-book hook](https://hl7.org/fhir/us/davinci-crd/STU2/hooks.html#appointment-book) + and the [CDS Hooks section on CDS Service Response](https://cds-hooks.hl7.org/2.0/#cds-service-response). + + The [CRD IG section on appointment-book hook](https://hl7.org/fhir/us/davinci-crd/STU2/hooks.html#appointment-book) + states that "servers SHALL, at minimum, support returning and processing the Coverage Information + system action for all invocations of this hook." + + This group includes tests to validate the following CRD response types: + - [Coverage Information](https://hl7.org/fhir/us/davinci-crd/STU2/cards.html#coverage-information) + - [Create or update coverage information](https://hl7.org/fhir/us/davinci-crd/STU2/cards.html#create-or-update-coverage-information)\ + - optional + - [External Reference](https://hl7.org/fhir/us/davinci-crd/STU2/cards.html#external-reference) - optional + - [Instructions](https://hl7.org/fhir/us/davinci-crd/STU2/cards.html#instructions) - optional + - [Launch SMART application](https://hl7.org/fhir/us/davinci-crd/STU2/cards.html#launch-smart-application) - + optional + - [Request form completion](https://hl7.org/fhir/us/davinci-crd/STU2/cards.html#request-form-completion) - + optional + ) + + config options: { hook_name: 'appointment-book' } + run_as_group + + test from: :crd_service_call_test, + config: { + inputs: { + service_ids: { + name: :appointment_book_service_ids, + title: 'Service id for the service that invokes `appointment-book` hook' + }, + service_request_bodies: { + name: :appointment_book_request_bodies, + title: 'Request bodies collection to use to invoke the `appointment-book` hook' + } + } + } + + test from: :crd_service_request_required_fields_validation, + config: { + outputs: { + contexts: { + name: :appointment_book_contexts + } + } + } + test from: :crd_service_request_context_validation, + config: { + inputs: { + contexts: { + name: :appointment_book_contexts + } + } + } + test from: :crd_service_request_optional_fields_validation + test from: :crd_service_response_validation, + config: { + outputs: { + valid_cards: { + name: :appointment_book_valid_cards + }, + valid_system_actions: { + name: :appointment_book_valid_system_actions + } + } + } + test from: :crd_card_optional_fields_validation, + config: { + inputs: { + valid_cards: { + name: :appointment_book_valid_cards + } + }, + outputs: { + valid_cards_with_links: { + name: :appointment_book_valid_cards_with_links + }, + valid_cards_with_suggestions: { + name: :appointment_book_valid_cards_with_suggestions + } + } + } + test from: :crd_external_reference_card_validation, + config: { + inputs: { + valid_cards_with_links: { + name: :appointment_book_valid_cards_with_links + } + } + } + test from: :crd_launch_smart_app_card_validation, + config: { + inputs: { + valid_cards_with_links: { + name: :appointment_book_valid_cards_with_links + } + } + } + test from: :crd_valid_instructions_card_received, + config: { + inputs: { + valid_cards: { + name: :appointment_book_valid_cards + } + } + } + test from: :crd_coverage_info_system_action_received, + config: { + inputs: { + valid_system_actions: { + name: :appointment_book_valid_system_actions + } + }, + outputs: { + coverage_info: { + name: :appointment_book_coverage_info + } + } + } + test from: :crd_coverage_info_system_action_validation, + config: { + inputs: { + coverage_info: { + name: :appointment_book_coverage_info + } + } + } + test from: :crd_request_form_completion_response_validation, + config: { + inputs: { + valid_system_actions: { + name: :appointment_book_valid_system_actions + }, + valid_cards_with_suggestions: { + name: :appointment_book_valid_cards_with_suggestions + } + } + } + test from: :crd_create_or_update_coverage_info_response_validation, + config: { + inputs: { + valid_system_actions: { + name: :appointment_book_valid_system_actions + }, + valid_cards_with_suggestions: { + name: :appointment_book_valid_cards_with_suggestions + } + } + } + end +end diff --git a/lib/davinci_crd_test_kit/server_discovery_group.rb b/lib/davinci_crd_test_kit/server_discovery_group.rb new file mode 100644 index 0000000..e54e9d6 --- /dev/null +++ b/lib/davinci_crd_test_kit/server_discovery_group.rb @@ -0,0 +1,59 @@ +require 'tls_test_kit' +require_relative 'server_tests/discovery_endpoint_test' +require_relative 'server_tests/discovery_services_validation_test' + +module DaVinciCRDTestKit + class ServerDiscoveryGroup < Inferno::TestGroup + title 'Discovery' + id :crd_server_discovery_group + description %( + # Background + + The #{title} Group checks for a CDS Service's Discovery endpoint as described by the + [CDS Hooks Specification](https://cds-hooks.hl7.org/2.0/#discovery). + A CDS Service is discoverable via a stable endpoint by CDS Clients. The Discovery endpoint + includes information such as a description of the CDS Service, when it should be invoked, + and any data that is requested to be prefetched. + The Discovery endpoint SHALL always be available at `{baseUrl}/cds-services`. + + # Test Methodology + + This test sequence accesses the CRD server Dicovery endpoint at /cds-services using a GET request. + It parses the response and verifies that: + - The Discovery endpoint is TLS secured. + - The Discovery endpoint is available at `{baseURL}/cds-services`. + - Each CDS Service in the response contains the required fields as specified in the [CDS Hooks Spec](https://cds-hooks.hl7.org/2.0/#response). + + It collects the following information that is saved in the testing session for use by later tests: + - List of supported CDS Services/Hooks + - List of service IDs for each supported Hook. + ) + + run_as_group + + test from: :tls_version_test do + title 'CRD Server is secured by transport layer security' + description <<~DESCRIPTION + Under [Privacy, Security, and Safety](https://hl7.org/fhir/us/davinci-crd/STU2/security.html), + the CRD Implementation Guide imposes the following rule about TLS: + + As per the [CDS Hook specification](https://cds-hooks.hl7.org/2.0/#security-and-safety), + communications between CRD Clients and CRD Servers SHALL + use TLS. Mutual TLS is not required by this specification but is permitted. CRD Servers and + CRD Clients SHOULD enforce a minimum version and other TLS configuration requirements based + on HRex rules for PHI exchange. + + This test verifies that the CRD server is using TLS 1.2 or higher. + DESCRIPTION + id :crd_server_tls_version_stu2 + + config( + options: { minimum_allowed_version: OpenSSL::SSL::TLS1_2_VERSION }, + inputs: { url: { name: :base_url } } + ) + end + + test from: :crd_discovery_endpoint_test + test from: :crd_discovery_services_validation + end +end diff --git a/lib/davinci_crd_test_kit/server_encounter_discharge_group.rb b/lib/davinci_crd_test_kit/server_encounter_discharge_group.rb new file mode 100644 index 0000000..7b2c95e --- /dev/null +++ b/lib/davinci_crd_test_kit/server_encounter_discharge_group.rb @@ -0,0 +1,144 @@ +require_relative 'server_tests/service_call_test' +require_relative 'server_tests/service_request_required_fields_validation_test' +require_relative 'server_tests/service_request_context_validation_test' +require_relative 'server_tests/service_request_optional_fields_validation_test' +require_relative 'server_tests/service_response_validation_test' +require_relative 'server_tests/card_optional_fields_validation_test' +require_relative 'server_tests/external_reference_card_validation_test' +require_relative 'server_tests/launch_smart_app_card_validation_test' +require_relative 'server_tests/instructions_card_received_test' +require_relative 'server_tests/form_completion_response_validation_test' +require_relative 'server_tests/create_or_update_coverage_info_response_validation_test' + +module DaVinciCRDTestKit + class ServerEncounterDischargeGroup < Inferno::TestGroup + title 'encounter-discharge' + id :crd_server_encounter_discharge + description %( + This group of tests invokes the encounter-discharge hook and ensures that + the user-provided requests are valid as per the requirements described + in the [CRD IG section on encounter-discharge hook](https://hl7.org/fhir/us/davinci-crd/STU2/hooks.html#encounter-discharge) + and the [CDS Hooks specification section on encounter-discharge context](https://cds-hooks.hl7.org/hooks/encounter-discharge/2023SepSTU1Ballot/encounter-discharge/). + It also ensures that the contents of the server's response are valid as per the requirements described in + the [CRD IG section on encounter-discharge hook](https://hl7.org/fhir/us/davinci-crd/STU2/hooks.html#encounter-discharge) + and the [CDS Hooks section on CDS Service Response](https://cds-hooks.hl7.org/2.0/#cds-service-response). + + This group includes tests to validate the following CRD response types: + - [Create or update coverage information](https://hl7.org/fhir/us/davinci-crd/STU2/cards.html#create-or-update-coverage-information)\ + - optional + - [External Reference](https://hl7.org/fhir/us/davinci-crd/STU2/cards.html#external-reference) - optional + - [Instructions](https://hl7.org/fhir/us/davinci-crd/STU2/cards.html#instructions) - optional + - [Launch SMART application](https://hl7.org/fhir/us/davinci-crd/STU2/cards.html#launch-smart-application) - + optional + - [Request form completion](https://hl7.org/fhir/us/davinci-crd/STU2/cards.html#request-form-completion) - + optional + ) + + config options: { hook_name: 'encounter-discharge' } + run_as_group + + test from: :crd_service_call_test, + config: { + inputs: { + service_ids: { + name: :encounter_discharge_service_ids, + title: 'Service id for the service that invokes `encounter-discharge` hook' + }, + service_request_bodies: { + name: :encounter_discharge_request_bodies, + title: 'Request bodies collection to use to invoke the `encounter-discharge` hook' + } + } + } + test from: :crd_service_request_required_fields_validation, + config: { + outputs: { + contexts: { + name: :encounter_discharge_contexts + } + } + } + test from: :crd_service_request_context_validation, + config: { + inputs: { + contexts: { + name: :encounter_discharge_contexts + } + } + } + test from: :crd_service_request_optional_fields_validation + test from: :crd_service_response_validation, + config: { + outputs: { + valid_cards: { + name: :encounter_discharge_valid_cards + }, + valid_system_actions: { + name: :encounter_discharge_valid_system_actions + } + } + } + test from: :crd_card_optional_fields_validation, + config: { + inputs: { + valid_cards: { + name: :encounter_discharge_valid_cards + } + }, + outputs: { + valid_cards_with_links: { + name: :encounter_discharge_valid_cards_with_links + }, + valid_cards_with_suggestions: { + name: :encounter_discharge_valid_cards_with_suggestions + } + } + } + test from: :crd_external_reference_card_validation, + config: { + inputs: { + valid_cards_with_links: { + name: :encounter_discharge_valid_cards_with_links + } + } + } + test from: :crd_launch_smart_app_card_validation, + config: { + inputs: { + valid_cards_with_links: { + name: :encounter_discharge_valid_cards_with_links + } + } + } + test from: :crd_valid_instructions_card_received, + config: { + inputs: { + valid_cards: { + name: :encounter_discharge_valid_cards + } + } + } + test from: :crd_request_form_completion_response_validation, + config: { + inputs: { + valid_system_actions: { + name: :encounter_discharge_valid_system_actions + }, + valid_cards_with_suggestions: { + name: :encounter_discharge_valid_cards_with_suggestions + } + } + } + test from: :crd_create_or_update_coverage_info_response_validation, + config: { + inputs: { + valid_system_actions: { + name: :encounter_discharge_valid_system_actions + }, + valid_cards_with_suggestions: { + name: :encounter_discharge_valid_cards_with_suggestions + } + } + } + end +end diff --git a/lib/davinci_crd_test_kit/server_encounter_start_group.rb b/lib/davinci_crd_test_kit/server_encounter_start_group.rb new file mode 100644 index 0000000..9debe4f --- /dev/null +++ b/lib/davinci_crd_test_kit/server_encounter_start_group.rb @@ -0,0 +1,144 @@ +require_relative 'server_tests/service_call_test' +require_relative 'server_tests/service_request_required_fields_validation_test' +require_relative 'server_tests/service_request_context_validation_test' +require_relative 'server_tests/service_request_optional_fields_validation_test' +require_relative 'server_tests/service_response_validation_test' +require_relative 'server_tests/card_optional_fields_validation_test' +require_relative 'server_tests/external_reference_card_validation_test' +require_relative 'server_tests/launch_smart_app_card_validation_test' +require_relative 'server_tests/instructions_card_received_test' +require_relative 'server_tests/form_completion_response_validation_test' +require_relative 'server_tests/create_or_update_coverage_info_response_validation_test' + +module DaVinciCRDTestKit + class ServerEncounterStartGroup < Inferno::TestGroup + title 'encounter-start' + id :crd_server_encounter_start + description %( + This group of tests invokes the encounter-start hook and ensures that + the user-provided requests are valid as per the requirements described + in the [CRD IG section on encounter-start hook](https://hl7.org/fhir/us/davinci-crd/STU2/hooks.html#encounter-start) + and the [CDS Hooks specification section on encounter-start context](https://cds-hooks.hl7.org/hooks/encounter-start/2023SepSTU1Ballot/encounter-start/). + It also ensures that the contents of the server's response are valid as per the requirements described in + the [CRD IG section on encounter-start hook](https://hl7.org/fhir/us/davinci-crd/STU2/hooks.html#encounter-start) + and the [CDS Hooks section on CDS Service Response](https://cds-hooks.hl7.org/2.0/#cds-service-response). + + This group includes tests to validate the following CRD response types: + - [Create or update coverage information](https://hl7.org/fhir/us/davinci-crd/STU2/cards.html#create-or-update-coverage-information)\ + - optional + - [External Reference](https://hl7.org/fhir/us/davinci-crd/STU2/cards.html#external-reference) - optional + - [Instructions](https://hl7.org/fhir/us/davinci-crd/STU2/cards.html#instructions) - optional + - [Launch SMART application](https://hl7.org/fhir/us/davinci-crd/STU2/cards.html#launch-smart-application) - + optional + - [Request form completion](https://hl7.org/fhir/us/davinci-crd/STU2/cards.html#request-form-completion) - + optional + ) + + config options: { hook_name: 'encounter-start' } + run_as_group + + test from: :crd_service_call_test, + config: { + inputs: { + service_ids: { + name: :encounter_start_service_ids, + title: 'Service id for the service that invokes `encounter-start` hook' + }, + service_request_bodies: { + name: :encounter_start_request_bodies, + title: 'Request bodies collection to use to invoke the `encounter-start` hook' + } + } + } + test from: :crd_service_request_required_fields_validation, + config: { + outputs: { + contexts: { + name: :encounter_start_contexts + } + } + } + test from: :crd_service_request_context_validation, + config: { + inputs: { + contexts: { + name: :encounter_start_contexts + } + } + } + test from: :crd_service_request_optional_fields_validation + test from: :crd_service_response_validation, + config: { + outputs: { + valid_cards: { + name: :encounter_start_valid_cards + }, + valid_system_actions: { + name: :encounter_start_valid_system_actions + } + } + } + test from: :crd_card_optional_fields_validation, + config: { + inputs: { + valid_cards: { + name: :encounter_start_valid_cards + } + }, + outputs: { + valid_cards_with_links: { + name: :encounter_start_valid_cards_with_links + }, + valid_cards_with_suggestions: { + name: :encounter_start_valid_cards_with_suggestions + } + } + } + test from: :crd_external_reference_card_validation, + config: { + inputs: { + valid_cards_with_links: { + name: :encounter_start_valid_cards_with_links + } + } + } + test from: :crd_launch_smart_app_card_validation, + config: { + inputs: { + valid_cards_with_links: { + name: :encounter_start_valid_cards_with_links + } + } + } + test from: :crd_valid_instructions_card_received, + config: { + inputs: { + valid_cards: { + name: :encounter_start_valid_cards + } + } + } + test from: :crd_request_form_completion_response_validation, + config: { + inputs: { + valid_system_actions: { + name: :encounter_start_valid_system_actions + }, + valid_cards_with_suggestions: { + name: :encounter_start_valid_cards_with_suggestions + } + } + } + test from: :crd_create_or_update_coverage_info_response_validation, + config: { + inputs: { + valid_system_actions: { + name: :encounter_start_valid_system_actions + }, + valid_cards_with_suggestions: { + name: :encounter_start_valid_cards_with_suggestions + } + } + } + end +end diff --git a/lib/davinci_crd_test_kit/server_hook_request_validation.rb b/lib/davinci_crd_test_kit/server_hook_request_validation.rb new file mode 100644 index 0000000..86a0f54 --- /dev/null +++ b/lib/davinci_crd_test_kit/server_hook_request_validation.rb @@ -0,0 +1,15 @@ +require_relative 'hook_request_field_validation' + +module DaVinciCRDTestKit + module ServerHookRequestValidation + include DaVinciCRDTestKit::HookRequestFieldValidation + + def client_test? + false + end + + def server_test? + true + end + end +end diff --git a/lib/davinci_crd_test_kit/server_hooks_group.rb b/lib/davinci_crd_test_kit/server_hooks_group.rb new file mode 100644 index 0000000..1a795db --- /dev/null +++ b/lib/davinci_crd_test_kit/server_hooks_group.rb @@ -0,0 +1,69 @@ +require_relative 'server_appointment_book_group' +require_relative 'server_encounter_start_group' +require_relative 'server_encounter_discharge_group' +require_relative 'server_order_select_group' +require_relative 'server_order_dispatch_group' +require_relative 'server_order_sign_group' +require_relative 'server_required_card_response_validation_group' + +module DaVinciCRDTestKit + class ServerHooksGroup < Inferno::TestGroup + title 'Hook Tests' + id :crd_server_hooks + description %( + # Background + + The #{title} Group verifies that a [CRD Server](https://hl7.org/fhir/us/davinci-crd/STU2/CapabilityStatement-crd-server.html) + supports at least one of the hooks supported by the [CRD IG](https://hl7.org/fhir/us/davinci-crd/STU2/hooks.html#supported-hooks). + The supported hooks include: + - [appointment-book](https://hl7.org/fhir/us/davinci-crd/STU2/hooks.html#appointment-book) + - [encounter-start](https://hl7.org/fhir/us/davinci-crd/STU2/hooks.html#encounter-start) + - [encounter-discharge](https://hl7.org/fhir/us/davinci-crd/STU2/hooks.html#encounter-discharge) + - [order-select](https://hl7.org/fhir/us/davinci-crd/STU2/hooks.html#order-select) + - [order-dispatch](https://hl7.org/fhir/us/davinci-crd/STU2/hooks.html#order-dispatch) + - [order-sign](https://hl7.org/fhir/us/davinci-crd/STU2/hooks.html#order-sign) + + The [CRD STU2 IG section on Supported Hooks](https://hl7.org/fhir/us/davinci-crd/STU2/hooks.html#supported-hooks) + states that "CRD Servers conforming to this implementation guide + SHALL provide a service for all hooks and order resource types required of + CRD clients by this implementation guide unless the server has determined that + the hook will not be reasonably useful in determining coverage or documentation + expectations for the types of coverage provided." + + # Test Methodology + + In these tests, Inferno acts as a [CRD Client](https://hl7.org/fhir/us/davinci-crd/STU2/CapabilityStatement-crd-client.html) + that initiates CDS Hooks calls. This test sequence is broken up into groups, + each group corresponding to a supported hook and defining a set of tests verifying + the ability of the server to respond to the given hook invocation. Additionally, an additional + group checks the required [response types](https://hl7.org/fhir/us/davinci-crd/STU2/cards.html#potential-crd-response-types) + across all hooks invoked. + + Each hook group test verifies that: + - The hook can be invoked. + - The user-provided request payload contains the required fields as specified + in the [CDS Hooks section on HTTP request requirements](https://cds-hooks.hl7.org/2.0/#http-request_1). + - The user-provided request payload contains the optional fields as specified + in the [CDS Hooks section on HTTP request requirements](https://cds-hooks.hl7.org/2.0/#http-request_1) - + optional. + - Each card and system action returned by the server is valid as described in the + [CDS Hooks section on CDS Service Response](https://cds-hooks.hl7.org/2.0/#cds-service-response). + - Each [CRD response type](https://hl7.org/fhir/us/davinci-crd/STU2/cards.html#potential-crd-response-types) + returned is valid - optional for some response types. See the individual test groups for more details. + ) + + group from: :crd_server_appointment_book, + optional: true + group from: :crd_server_encounter_start, + optional: true + group from: :crd_server_encounter_discharge, + optional: true + group from: :crd_server_order_select, + optional: true + group from: :crd_server_order_dispatch, + optional: true + group from: :crd_server_order_sign, + optional: true + group from: :crd_server_required_card_response_validation + end +end diff --git a/lib/davinci_crd_test_kit/server_order_dispatch_group.rb b/lib/davinci_crd_test_kit/server_order_dispatch_group.rb new file mode 100644 index 0000000..726b2e2 --- /dev/null +++ b/lib/davinci_crd_test_kit/server_order_dispatch_group.rb @@ -0,0 +1,173 @@ +require_relative 'server_tests/service_call_test' +require_relative 'server_tests/service_request_required_fields_validation_test' +require_relative 'server_tests/service_request_context_validation_test' +require_relative 'server_tests/service_request_optional_fields_validation_test' +require_relative 'server_tests/service_response_validation_test' +require_relative 'server_tests/card_optional_fields_validation_test' +require_relative 'server_tests/external_reference_card_validation_test' +require_relative 'server_tests/launch_smart_app_card_validation_test' +require_relative 'server_tests/instructions_card_received_test' +require_relative 'server_tests/coverage_information_system_action_received_test' +require_relative 'server_tests/coverage_information_system_action_validation_test' +require_relative 'server_tests/form_completion_response_validation_test' +require_relative 'server_tests/create_or_update_coverage_info_response_validation_test' + +module DaVinciCRDTestKit + class ServerOrderDispatchGroup < Inferno::TestGroup + title 'order-dispatch' + id :crd_server_order_dispatch + description %( + This group of tests invokes the order-dispatch hook and ensures that + the user-provided requests are valid as per the requirements described + in the [CRD IG section on order-dispatch hook](https://hl7.org/fhir/us/davinci-crd/STU2/hooks.html#order-dispatch) + and the [CDS Hooks specification section on order-dispatch context](https://cds-hooks.hl7.org/hooks/order-dispatch/2023SepSTU1Ballot/order-dispatch/). + It also ensures that the contents of the server's response are valid as per the requirements described in + the [CRD IG section on order-dispatch hook](https://hl7.org/fhir/us/davinci-crd/STU2/hooks.html#order-dispatch) + and the [CDS Hooks section on CDS Service Response](https://cds-hooks.hl7.org/2.0/#cds-service-response). + + The [CRD IG section on order-dispatch hook](https://hl7.org/fhir/us/davinci-crd/STU2/hooks.html#order-dispatch) + states that "servers SHALL, at minimum, support returning and processing the Coverage Information + system action for all invocations of this hook." + + This group includes tests to validate the following CRD response types: + - [Coverage Information](https://hl7.org/fhir/us/davinci-crd/STU2/cards.html#coverage-information) + - [Create or update coverage information](https://hl7.org/fhir/us/davinci-crd/STU2/cards.html#create-or-update-coverage-information)\ + - optional + - [External Reference](https://hl7.org/fhir/us/davinci-crd/STU2/cards.html#external-reference) - optional + - [Instructions](https://hl7.org/fhir/us/davinci-crd/STU2/cards.html#instructions) - optional + - [Launch SMART application](https://hl7.org/fhir/us/davinci-crd/STU2/cards.html#launch-smart-application) - + optional + - [Request form completion](https://hl7.org/fhir/us/davinci-crd/STU2/cards.html#request-form-completion) - + optional + ) + + config options: { hook_name: 'order-dispatch' } + run_as_group + + test from: :crd_service_call_test, + config: { + inputs: { + service_ids: { + name: :order_dispatch_service_ids, + title: 'Service id for the service that invokes `order-dispatch` hook' + }, + service_request_bodies: { + name: :order_dispatch_request_bodies, + title: 'Request bodies collection to use to invoke the `order-dispatch` hook' + } + } + } + + test from: :crd_service_request_required_fields_validation, + config: { + outputs: { + contexts: { + name: :order_dispatch_contexts + } + } + } + test from: :crd_service_request_context_validation, + config: { + inputs: { + contexts: { + name: :order_dispatch_contexts + } + } + } + test from: :crd_service_request_optional_fields_validation + test from: :crd_service_response_validation, + config: { + outputs: { + valid_cards: { + name: :order_dispatch_valid_cards + }, + valid_system_actions: { + name: :order_dispatch_valid_system_actions + } + } + } + test from: :crd_card_optional_fields_validation, + config: { + inputs: { + valid_cards: { + name: :order_dispatch_valid_cards + } + }, + outputs: { + valid_cards_with_links: { + name: :order_dispatch_valid_cards_with_links + }, + valid_cards_with_suggestions: { + name: :order_dispatch_valid_cards_with_suggestions + } + } + } + test from: :crd_external_reference_card_validation, + config: { + inputs: { + valid_cards_with_links: { + name: :order_dispatch_valid_cards_with_links + } + } + } + test from: :crd_launch_smart_app_card_validation, + config: { + inputs: { + valid_cards_with_links: { + name: :order_dispatch_valid_cards_with_links + } + } + } + test from: :crd_valid_instructions_card_received, + config: { + inputs: { + valid_cards: { + name: :order_dispatch_valid_cards + } + } + } + test from: :crd_coverage_info_system_action_received, + config: { + inputs: { + valid_system_actions: { + name: :order_dispatch_valid_system_actions + } + }, + outputs: { + coverage_info: { + name: :order_dispatch_coverage_info + } + } + } + test from: :crd_coverage_info_system_action_validation, + config: { + inputs: { + coverage_info: { + name: :order_dispatch_coverage_info + } + } + } + test from: :crd_request_form_completion_response_validation, + config: { + inputs: { + valid_system_actions: { + name: :order_dispatch_valid_system_actions + }, + valid_cards_with_suggestions: { + name: :order_dispatch_valid_cards_with_suggestions + } + } + } + test from: :crd_create_or_update_coverage_info_response_validation, + config: { + inputs: { + valid_system_actions: { + name: :order_dispatch_valid_system_actions + }, + valid_cards_with_suggestions: { + name: :order_dispatch_valid_cards_with_suggestions + } + } + } + end +end diff --git a/lib/davinci_crd_test_kit/server_order_select_group.rb b/lib/davinci_crd_test_kit/server_order_select_group.rb new file mode 100644 index 0000000..86cae4a --- /dev/null +++ b/lib/davinci_crd_test_kit/server_order_select_group.rb @@ -0,0 +1,169 @@ +require_relative 'server_tests/service_call_test' +require_relative 'server_tests/service_request_required_fields_validation_test' +require_relative 'server_tests/service_request_context_validation_test' +require_relative 'server_tests/service_request_optional_fields_validation_test' +require_relative 'server_tests/service_response_validation_test' +require_relative 'server_tests/card_optional_fields_validation_test' +require_relative 'server_tests/external_reference_card_validation_test' +require_relative 'server_tests/launch_smart_app_card_validation_test' +require_relative 'server_tests/instructions_card_received_test' +require_relative 'server_tests/propose_alternate_request_card_validation_test' +require_relative 'server_tests/additional_orders_validation_test' +require_relative 'server_tests/form_completion_response_validation_test' +require_relative 'server_tests/create_or_update_coverage_info_response_validation_test' + +module DaVinciCRDTestKit + class ServerOrderSelectGroup < Inferno::TestGroup + title 'order-select' + id :crd_server_order_select + description %( + This group of tests invokes the order-select hook and ensures that + the user-provided requests are valid as per the requirements described + in the [CRD IG section on order-select hook](https://hl7.org/fhir/us/davinci-crd/STU2/hooks.html#order-select) + and the [CDS Hooks specification section on order-select context](https://cds-hooks.hl7.org/hooks/order-select/2023SepSTU1Ballot/order-select/). + It also ensures that the contents of the server's response are valid as per the requirements described in + the [CRD IG section on order-select hook](https://hl7.org/fhir/us/davinci-crd/STU2/hooks.html#order-select) + and the [CDS Hooks section on CDS Service Response](https://cds-hooks.hl7.org/2.0/#cds-service-response). + + This group includes tests to validate the following CRD response types: + - [additional orders as companions/prerequisites](https://hl7.org/fhir/us/davinci-crd/STU2/cards.html#identify-additional-orders-as-companionsprerequisites-for-current-order)\ + - optional + - [Create or update coverage information](https://hl7.org/fhir/us/davinci-crd/STU2/cards.html#create-or-update-coverage-information)\ + - optional + - [External Reference](https://hl7.org/fhir/us/davinci-crd/STU2/cards.html#external-reference) - optional + - [Instructions](https://hl7.org/fhir/us/davinci-crd/STU2/cards.html#instructions) - optional + - [Launch SMART application](https://hl7.org/fhir/us/davinci-crd/STU2/cards.html#launch-smart-application) - + optional + - [Propose alternate request](https://hl7.org/fhir/us/davinci-crd/STU2/cards.html#propose-alternate-request) - + optional + - [Request form completion](https://hl7.org/fhir/us/davinci-crd/STU2/cards.html#request-form-completion) - + optional + ) + + config options: { hook_name: 'order-select' } + run_as_group + + test from: :crd_service_call_test, + config: { + inputs: { + service_ids: { + name: :order_select_service_ids, + title: 'Service id for the service that invokes `order-select` hook' + }, + service_request_bodies: { + name: :order_select_request_bodies, + title: 'Request bodies collection to use to invoke the `order-select` hook' + } + } + } + test from: :crd_service_request_required_fields_validation, + config: { + outputs: { + contexts: { + name: :order_select_contexts + } + } + } + test from: :crd_service_request_context_validation, + config: { + inputs: { + contexts: { + name: :order_select_contexts + } + } + } + test from: :crd_service_request_optional_fields_validation + test from: :crd_service_response_validation, + config: { + outputs: { + valid_cards: { + name: :order_select_valid_cards + }, + valid_system_actions: { + name: :order_select_valid_system_actions + } + } + } + test from: :crd_card_optional_fields_validation, + config: { + inputs: { + valid_cards: { + name: :order_select_valid_cards + } + }, + outputs: { + valid_cards_with_links: { + name: :order_select_valid_cards_with_links + }, + valid_cards_with_suggestions: { + name: :order_select_valid_cards_with_suggestions + } + } + } + test from: :crd_external_reference_card_validation, + config: { + inputs: { + valid_cards_with_links: { + name: :order_select_valid_cards_with_links + } + } + } + test from: :crd_launch_smart_app_card_validation, + config: { + inputs: { + valid_cards_with_links: { + name: :order_select_valid_cards_with_links + } + } + } + test from: :crd_valid_instructions_card_received, + config: { + inputs: { + valid_cards: { + name: :order_select_valid_cards + } + } + } + test from: :crd_propose_alternate_request_card_validation, + config: { + inputs: { + valid_cards_with_suggestions: { + name: :order_select_valid_cards_with_suggestions + }, + contexts: { + name: :order_select_contexts + } + } + } + test from: :crd_additional_orders_card_validation, + config: { + inputs: { + valid_cards_with_suggestions: { + name: :order_select_valid_cards_with_suggestions + } + } + } + test from: :crd_request_form_completion_response_validation, + config: { + inputs: { + valid_system_actions: { + name: :order_select_valid_system_actions + }, + valid_cards_with_suggestions: { + name: :order_select_valid_cards_with_suggestions + } + } + } + test from: :crd_create_or_update_coverage_info_response_validation, + config: { + inputs: { + valid_system_actions: { + name: :order_select_valid_system_actions + }, + valid_cards_with_suggestions: { + name: :order_select_valid_cards_with_suggestions + } + } + } + end +end diff --git a/lib/davinci_crd_test_kit/server_order_sign_group.rb b/lib/davinci_crd_test_kit/server_order_sign_group.rb new file mode 100644 index 0000000..317b487 --- /dev/null +++ b/lib/davinci_crd_test_kit/server_order_sign_group.rb @@ -0,0 +1,198 @@ +require_relative 'server_tests/service_call_test' +require_relative 'server_tests/service_request_required_fields_validation_test' +require_relative 'server_tests/service_request_context_validation_test' +require_relative 'server_tests/service_request_optional_fields_validation_test' +require_relative 'server_tests/service_response_validation_test' +require_relative 'server_tests/card_optional_fields_validation_test' +require_relative 'server_tests/external_reference_card_validation_test' +require_relative 'server_tests/launch_smart_app_card_validation_test' +require_relative 'server_tests/instructions_card_received_test' +require_relative 'server_tests/coverage_information_system_action_received_test' +require_relative 'server_tests/coverage_information_system_action_validation_test' +require_relative 'server_tests/propose_alternate_request_card_validation_test' +require_relative 'server_tests/additional_orders_validation_test' +require_relative 'server_tests/form_completion_response_validation_test' +require_relative 'server_tests/create_or_update_coverage_info_response_validation_test' + +module DaVinciCRDTestKit + class ServerOrderSignGroup < Inferno::TestGroup + title 'order-sign' + id :crd_server_order_sign + description %( + This group of tests invokes the order-sign hook and ensures that + the user-provided requests are valid as per the requirements described + in the [CRD IG section on order-sign hook](https://hl7.org/fhir/us/davinci-crd/STU2/hooks.html#order-sign) + and the [CDS Hooks specification section on order-sign context](https://cds-hooks.hl7.org/hooks/order-sign/2023SepSTU1Ballot/order-sign/). + It also ensures that the contents of the server's response are valid as per the requirements described in + the [CRD IG section on order-sign hook](https://hl7.org/fhir/us/davinci-crd/STU2/hooks.html#order-sign) + and the [CDS Hooks section on CDS Service Response](https://cds-hooks.hl7.org/2.0/#cds-service-response). + + The [CRD IG section on order-sign hook](https://hl7.org/fhir/us/davinci-crd/STU2/hooks.html#order-sign) + states that "servers SHALL, at minimum, support returning and processing the Coverage Information + system action for all invocations of this hook." + + This group includes tests to validate the following CRD response types: + - [additional orders as companions/prerequisites](https://hl7.org/fhir/us/davinci-crd/STU2/cards.html#identify-additional-orders-as-companionsprerequisites-for-current-order)\ + - optional + - [Coverage Information](https://hl7.org/fhir/us/davinci-crd/STU2/cards.html#coverage-information) + - [Create or update coverage information](https://hl7.org/fhir/us/davinci-crd/STU2/cards.html#create-or-update-coverage-information)\ + - optional + - [External Reference](https://hl7.org/fhir/us/davinci-crd/STU2/cards.html#external-reference) - optional + - [Instructions](https://hl7.org/fhir/us/davinci-crd/STU2/cards.html#instructions) - optional + - [Launch SMART application](https://hl7.org/fhir/us/davinci-crd/STU2/cards.html#launch-smart-application) - + optional + - [Propose alternate request](https://hl7.org/fhir/us/davinci-crd/STU2/cards.html#propose-alternate-request) - + optional + - [Request form completion](https://hl7.org/fhir/us/davinci-crd/STU2/cards.html#request-form-completion) - + optional + ) + + config options: { hook_name: 'order-sign' } + run_as_group + + test from: :crd_service_call_test, + config: { + inputs: { + service_ids: { + name: :order_sign_service_ids, + title: 'Service id for the service that invokes `order-sign` hook' + }, + service_request_bodies: { + name: :order_sign_request_bodies, + title: 'Request bodies collection to use to invoke the `order-sign` hook' + } + } + } + + test from: :crd_service_request_required_fields_validation, + config: { + outputs: { + contexts: { + name: :order_sign_contexts + } + } + } + test from: :crd_service_request_context_validation, + config: { + inputs: { + contexts: { + name: :order_sign_contexts + } + } + } + test from: :crd_service_request_optional_fields_validation + test from: :crd_service_response_validation, + config: { + outputs: { + valid_cards: { + name: :order_sign_valid_cards + }, + valid_system_actions: { + name: :order_sign_valid_system_actions + } + } + } + test from: :crd_card_optional_fields_validation, + config: { + inputs: { + valid_cards: { + name: :order_sign_valid_cards + } + }, + outputs: { + valid_cards_with_links: { + name: :order_sign_valid_cards_with_links + }, + valid_cards_with_suggestions: { + name: :order_sign_valid_cards_with_suggestions + } + } + } + test from: :crd_external_reference_card_validation, + config: { + inputs: { + valid_cards_with_links: { + name: :order_sign_valid_cards_with_links + } + } + } + test from: :crd_launch_smart_app_card_validation, + config: { + inputs: { + valid_cards_with_links: { + name: :order_sign_valid_cards_with_links + } + } + } + test from: :crd_valid_instructions_card_received, + config: { + inputs: { + valid_cards: { + name: :order_sign_valid_cards + } + } + } + test from: :crd_coverage_info_system_action_received, + config: { + inputs: { + valid_system_actions: { + name: :order_sign_valid_system_actions + } + }, + outputs: { + coverage_info: { + name: :order_sign_coverage_info + } + } + } + test from: :crd_coverage_info_system_action_validation, + config: { + inputs: { + coverage_info: { + name: :order_sign_coverage_info + } + } + } + test from: :crd_propose_alternate_request_card_validation, + config: { + inputs: { + valid_cards_with_suggestions: { + name: :order_sign_valid_cards_with_suggestions + }, + contexts: { + name: :order_sign_contexts + } + } + } + test from: :crd_additional_orders_card_validation, + config: { + inputs: { + valid_cards_with_suggestions: { + name: :order_sign_valid_cards_with_suggestions + } + } + } + test from: :crd_request_form_completion_response_validation, + config: { + inputs: { + valid_system_actions: { + name: :order_sign_valid_system_actions + }, + valid_cards_with_suggestions: { + name: :order_sign_valid_cards_with_suggestions + } + } + } + test from: :crd_create_or_update_coverage_info_response_validation, + config: { + inputs: { + valid_system_actions: { + name: :order_sign_valid_system_actions + }, + valid_cards_with_suggestions: { + name: :order_sign_valid_cards_with_suggestions + } + } + } + end +end diff --git a/lib/davinci_crd_test_kit/server_required_card_response_validation_group.rb b/lib/davinci_crd_test_kit/server_required_card_response_validation_group.rb new file mode 100644 index 0000000..2622d62 --- /dev/null +++ b/lib/davinci_crd_test_kit/server_required_card_response_validation_group.rb @@ -0,0 +1,23 @@ +require_relative 'server_tests/external_reference_card_across_hooks_validation_test' +require_relative 'server_tests/instructions_card_received_across_hooks_test' +require_relative 'server_tests/coverage_information_system_action_across_hooks_validation_test' + +module DaVinciCRDTestKit + class ServerRequiredCardResponseValidationGroup < Inferno::TestGroup + title 'Required Card Response Validation' + description %( + This group contains tests to verify the presence and validity of required response types + across all hooks invoked. As per the [Da Vinci CRD Implementation Guide](https://hl7.org/fhir/us/davinci-crd/STU2/cards.html#potential-crd-response-types), + CRD Servers SHALL, at minimum, demonstrate an ability to return the following response types: + - Coverage Information System Action + - External Reference Card + - Instructions Card + ) + id :crd_server_required_card_response_validation + run_as_group + + test from: :crd_external_reference_card_across_hooks_validation + test from: :crd_valid_instructions_card_received_across_hooks + test from: :crd_coverage_info_system_action_across_hooks_validation + end +end diff --git a/lib/davinci_crd_test_kit/server_tests/additional_orders_validation_test.rb b/lib/davinci_crd_test_kit/server_tests/additional_orders_validation_test.rb new file mode 100644 index 0000000..8ef1572 --- /dev/null +++ b/lib/davinci_crd_test_kit/server_tests/additional_orders_validation_test.rb @@ -0,0 +1,70 @@ +require_relative '../test_helper' +require_relative '../suggestion_actions_validation' + +module DaVinciCRDTestKit + class AdditionalOrdersValidationTest < Inferno::Test + include DaVinciCRDTestKit::TestHelper + include DaVinciCRDTestKit::SuggestionActionsValidation + + title 'Valid Additional Orders as companions/prerequisites cards received' + id :crd_additional_orders_card_validation + description %( + This test validates that an [Additional Orders as companions/prerequisites](https://hl7.org/fhir/us/davinci-crd/STU2/cards.html#identify-additional-orders-as-companionsprerequisites-for-current-order) + card was received. It does so by: + - Filtering cards with the following criteria: + - For each suggestion in the card's suggestions array, all actions have a type of 'create' + and the action's resource type is one of the expected types: CommunicationRequest, Device, + DeviceRequest, Medication, MedicationRequest, NutritionOrder, ServiceRequest, or VisionPrescription. + - Then, for each valid Additional Orders card retrieved, verifying that each action within the + card's suggestions complies with their respective profiles as specified in the + [CRD IG section on Additional Orders as companions/prerequisites](https://hl7.org/fhir/us/davinci-crd/STU2/cards.html#identify-additional-orders-as-companionsprerequisites-for-current-order): + - [crd-profile-communicationrequest](https://hl7.org/fhir/us/davinci-crd/STU2/StructureDefinition-profile-communicationrequest.html) + - [crd-profile-device](https://hl7.org/fhir/us/davinci-crd/STU2/StructureDefinition-profile-device.html) + - [crd-profile-deviceRequest](https://hl7.org/fhir/us/davinci-crd/STU2/StructureDefinition-profile-devicerequest.html) + - [us-core-medication](http://hl7.org/fhir/us/core/STU3.1.1/StructureDefinition-us-core-medication.html) + - [crd-profile-medicationRequest](https://hl7.org/fhir/us/davinci-crd/STU2/StructureDefinition-profile-medicationrequest.html) + - [crd-profile-nutritionOrder](https://hl7.org/fhir/us/davinci-crd/STU2/StructureDefinition-profile-nutritionorder.html) + - [crd-profile-serviceRequest](https://hl7.org/fhir/us/davinci-crd/STU2/StructureDefinition-profile-servicerequest.html) + - [crd-profile-visionPrescription](https://hl7.org/fhir/us/davinci-crd/STU2/StructureDefinition-profile-visionprescription.html). + + The test will skip if no Additional Orders cards are found. + ) + + optional + input :valid_cards_with_suggestions + + EXPECTED_RESOURCE_TYPES = %w[ + CommunicationRequest Device DeviceRequest Medication + MedicationRequest NutritionOrder ServiceRequest + VisionPrescription + ].freeze + + def hook_name + config.options[:hook_name] + end + + def additional_orders_card?(card) + card['suggestions'].all? do |suggestion| + actions = suggestion['actions'] + actions&.all? do |action| + action['type'] == 'create' && action_resource_type_check(action, EXPECTED_RESOURCE_TYPES) + end + end + end + + run do + parsed_cards = parse_json(valid_cards_with_suggestions) + additional_orders_cards = parsed_cards.filter { |card| additional_orders_card?(card) } + skip_if additional_orders_cards.blank?, + "#{hook_name} hook response does not contain an Additional Orders as companions/prerequisites card." + + additional_orders_cards.each do |card| + card['suggestions'].each do |suggestion| + actions_check(suggestion['actions']) + end + end + + no_error_validation('Some Additional Orders as companions/prerequisites cards are not valid.') + end + end +end diff --git a/lib/davinci_crd_test_kit/server_tests/card_optional_fields_validation_test.rb b/lib/davinci_crd_test_kit/server_tests/card_optional_fields_validation_test.rb new file mode 100644 index 0000000..cb2fd58 --- /dev/null +++ b/lib/davinci_crd_test_kit/server_tests/card_optional_fields_validation_test.rb @@ -0,0 +1,47 @@ +require_relative '../test_helper' +require_relative '../cards_validation' + +module DaVinciCRDTestKit + class CardOptionalFieldsValidationTest < Inferno::Test + include DaVinciCRDTestKit::TestHelper + include DaVinciCRDTestKit::CardsValidation + + title 'Cards contain valid optional fields' + id :crd_card_optional_fields_validation + description %( + This test checks for the presence and validity of optional fields in a card, + but only if the card's required fields are valid. As specified in the + [CDS Hooks specification section on Card Attributes](https://cds-hooks.hl7.org/2.0/#card-attributes), + the optional fields include `uuid`, `detail`, `suggestions`, `overrideReasons`, and `links`. + + Additionally, the test validates the presence of the conditional field `selectionBehavior` + only if the `suggestions` field is present. + ) + optional + input :valid_cards + output :valid_cards_with_links, :valid_cards_with_suggestions + + def cards_with_suggestions + @cards_with_suggestions ||= [] + end + + def cards_with_links + @cards_with_links ||= [] + end + + run do + parsed_cards = parse_json(valid_cards) + parsed_cards.each do |card| + if valid_card_with_optionals?(card) + cards_with_links << card if card['links'] + cards_with_suggestions << card if card['suggestions'] + end + end + + output valid_cards_with_links: cards_with_links.to_json + output valid_cards_with_suggestions: cards_with_suggestions.to_json + + no_error_validation('Some cards with valid required fields have invalid optional fields.') + end + end +end diff --git a/lib/davinci_crd_test_kit/server_tests/coverage_information_system_action_across_hooks_validation_test.rb b/lib/davinci_crd_test_kit/server_tests/coverage_information_system_action_across_hooks_validation_test.rb new file mode 100644 index 0000000..4b063d6 --- /dev/null +++ b/lib/davinci_crd_test_kit/server_tests/coverage_information_system_action_across_hooks_validation_test.rb @@ -0,0 +1,32 @@ +require_relative '../test_helper' + +module DaVinciCRDTestKit + class CoverageInformationSystemActionAcrossHooksValidationTest < Inferno::Test + include DaVinciCRDTestKit::TestHelper + + title 'Valid Coverage Information system actions received across all hooks' + id :crd_coverage_info_system_action_across_hooks_validation + description %( + This test verifies the presence of valid [Coverage Information](https://hl7.org/fhir/us/davinci-crd/STU2/cards.html#coverage-information) + system action returned by CRD services across all hooks invoked. It verifies the following for each action: + - The action type is `update`. + - The resource within the action conforms its respective FHIR profile. + + Additionally, the test examines the `coverage-info` extensions within the resource to ensure that: + - Entries referencing differing coverage have distinct `coverage-assertion-ids` and `satisfied-pa-ids` + (if present). + - Entries referencing the same coverage have the same `coverage-assertion-ids` and `satisfied-pa-ids` + (if present). + + The test will be skipped if no valid Coverage Information system actions are returned across all hooks. + ) + + run do + verify_at_least_one_test_passes( + self.class.parent.parent.groups, + 'crd_coverage_info_system_action_validation', + 'None of the hooks invoked returned valid Coverage Info system actions.' + ) + end + end +end diff --git a/lib/davinci_crd_test_kit/server_tests/coverage_information_system_action_received_test.rb b/lib/davinci_crd_test_kit/server_tests/coverage_information_system_action_received_test.rb new file mode 100644 index 0000000..fd458bf --- /dev/null +++ b/lib/davinci_crd_test_kit/server_tests/coverage_information_system_action_received_test.rb @@ -0,0 +1,58 @@ +require_relative '../test_helper' +module DaVinciCRDTestKit + class CoverageInformationSystemActionReceivedTest < Inferno::Test + include DaVinciCRDTestKit::TestHelper + + title 'Coverage Information system action was received' + id :crd_coverage_info_system_action_received + description %( + This test validates that a [Coverage Information](https://hl7.org/fhir/us/davinci-crd/STU2/cards.html#coverage-information) + system action was returned. It does so by: + - First checking for the presence of actions with a `resource` element of the following types: + - For `appointment-book`: Appointment + - For `order-sign` or `order-dispatch`: DeviceRequest, MedicationRequest, NutritionOrder, + ServiceRequest, or VisionPrescription + - Then, among the target actions, checking if their resource has the [coverage-information extension](http://hl7.org/fhir/us/davinci-crd/StructureDefinition/ext-coverage-information). + ) + + input :valid_system_actions + output :coverage_info + + def hook_name + config.options[:hook_name] + end + + def resources_by_hook + shared_resources = [ + 'DeviceRequest', 'MedicationRequest', 'NutritionOrder', + 'ServiceRequest', 'VisionPrescription' + ] + { + 'appointment-book' => ['Appointment'], + 'order-sign' => shared_resources, + 'order-dispatch' => shared_resources + } + end + + run do + parsed_actions = parse_json(valid_system_actions) + target_resources = resources_by_hook[hook_name] + + target_actions = parsed_actions.select do |action| + resource = FHIR.from_contents(action['resource'].to_json) + target_resources.include?(resource&.resourceType) + end + + coverage_info_system_actions = target_actions.select do |action| + resource = FHIR.from_contents(action['resource'].to_json) + coverage_info_ext_url = 'http://hl7.org/fhir/us/davinci-crd/StructureDefinition/ext-coverage-information' + resource.extension.any? { |extension| extension.url == coverage_info_ext_url } + end + + assert coverage_info_system_actions.present?, + "Coverage Information system action was not returned in the #{hook_name} hook response." + + output coverage_info: coverage_info_system_actions.to_json + end + end +end diff --git a/lib/davinci_crd_test_kit/server_tests/coverage_information_system_action_validation_test.rb b/lib/davinci_crd_test_kit/server_tests/coverage_information_system_action_validation_test.rb new file mode 100644 index 0000000..7a3426c --- /dev/null +++ b/lib/davinci_crd_test_kit/server_tests/coverage_information_system_action_validation_test.rb @@ -0,0 +1,121 @@ +require_relative '../server_hook_request_validation' +require_relative '../test_helper' + +module DaVinciCRDTestKit + class CoverageInformationSystemActionValidationTest < Inferno::Test + include DaVinciCRDTestKit::ServerHookRequestValidation + include DaVinciCRDTestKit::TestHelper + + title 'All Coverage Information system actions received are valid' + id :crd_coverage_info_system_action_validation + description %( + This test validates all [Coverage Information](https://hl7.org/fhir/us/davinci-crd/STU2/cards.html#coverage-information) + system actions received. It verifies the following for each action: + - The action type is `update`. + - The resource within the action conforms its respective FHIR profile. + + Additionally, the test examines the `coverage-info` extensions within the resource to ensure that: + - Entries referencing differing coverage have distinct `coverage-assertion-ids` and `satisfied-pa-ids` + (if present). + - Entries referencing the same coverage have the same `coverage-assertion-ids` and `satisfied-pa-ids` + (if present). + ) + input :coverage_info + + def hook_name + config.options[:hook_name] + end + + def find_extension_value(extension, url, *properties) + found_extension = extension.extension.find { |ext| ext.url == url } + return nil unless found_extension + + properties.reduce(found_extension) do |current, prop| + return current unless current.respond_to?(prop) + + current.send(prop) + end + end + + def extract_and_group_coverage_info(resource) + resource.extension.each_with_object({}) do |extension, grouped_extensions| + next unless extension.url == 'http://hl7.org/fhir/us/davinci-crd/StructureDefinition/ext-coverage-information' + + coverage_key = find_extension_value(extension, 'coverage', 'valueReference', 'reference') + grouped_extensions[coverage_key] ||= [] + grouped_extensions[coverage_key] << extension + end + end + + # For the same coverage, ensure coverage-assertion-ids and satisfied-pa-ids are the same. + # For different coverages, ensure coverage-assertion-ids and satisfied-pa-ids are distinct. + def multiple_extensions_conformance_check(grouped_coverage_info, resource) + resource_ref = "#{resource.resourceType}/#{resource.id}" + assertion_ids_across_coverages = Set.new + pa_ids_across_coverages = Set.new + + grouped_coverage_info.each do |coverage, extensions| + coverage_assertion_ids = collect_extensions_id(extensions, 'coverage-assertion-id', 'valueString').uniq + satisfied_pa_ids = collect_extensions_id(extensions, 'satisfied-pa-id', 'valueString').uniq.compact + assert coverage_assertion_ids.length == 1, + same_coverage_conformance_error_msg(resource_ref, coverage, 'coverage-assertion-ids') + + assert satisfied_pa_ids.length <= 1, + same_coverage_conformance_error_msg(resource_ref, coverage, 'satisfied-pa-ids') + + assertion_id = coverage_assertion_ids.first + assert !assertion_ids_across_coverages.include?(assertion_id), + different_coverage_conformance_error_msg(resource_ref, 'coverage-assertion-ids') + assertion_ids_across_coverages.add(assertion_id) + pa_id = satisfied_pa_ids.first + next unless pa_id + + assert !pa_ids_across_coverages.include?(pa_id), + different_coverage_conformance_error_msg(resource_ref, 'satisfied-pa-ids') + pa_ids_across_coverages.add(pa_id) + end + end + + def collect_extensions_id(extensions, url, *properties) + extensions.map do |extension| + find_extension_value(extension, url, *properties) + end + end + + def same_coverage_conformance_error_msg(resource_ref, coverage, id_name) + "#{resource_ref}: extension has multiple repetitions of coverage `#{coverage}` with different #{id_name}." + end + + def different_coverage_conformance_error_msg(resource_ref, id_name) + "#{resource_ref}: extensions referencing differing coverage SHALL have distinct #{id_name}." + end + + def coverage_info_system_action_check(coverage_info_system_action) + type = coverage_info_system_action['type'] + assert type, '`type` field is missing.' + assert type == 'update', "`type` must be `update`, but was `#{type}`" + + resource = FHIR.from_contents(coverage_info_system_action['resource'].to_json) + profile_url = structure_definition_map[resource.resourceType] + assert_valid_resource(resource:, profile_url:) + + grouped_coverage_info = extract_and_group_coverage_info(resource) + multiple_extensions_conformance_check(grouped_coverage_info, resource) + end + + run do + parsed_coverage_info = parse_json(coverage_info) + error_messages = [] + parsed_coverage_info.each do |action| + coverage_info_system_action_check(action) + rescue Inferno::Exceptions::AssertionException => e + error_messages << "Coverage Info system action `#{action}`: #{e.message}" + end + + error_messages.each do |msg| + messages << { type: 'error', message: msg } + end + assert error_messages.empty?, 'Some Coverage Info system actions are not valid.' + end + end +end diff --git a/lib/davinci_crd_test_kit/server_tests/create_or_update_coverage_info_response_validation_test.rb b/lib/davinci_crd_test_kit/server_tests/create_or_update_coverage_info_response_validation_test.rb new file mode 100644 index 0000000..a0a5005 --- /dev/null +++ b/lib/davinci_crd_test_kit/server_tests/create_or_update_coverage_info_response_validation_test.rb @@ -0,0 +1,72 @@ +require_relative '../test_helper' +require_relative '../suggestion_actions_validation' + +module DaVinciCRDTestKit + class CreateOrUpdateCoverageInfoResponseValidationTest < Inferno::Test + include DaVinciCRDTestKit::TestHelper + include DaVinciCRDTestKit::SuggestionActionsValidation + + title 'Valid Create or Update Coverage Information cards or system actions received' + id :crd_create_or_update_coverage_info_response_validation + description %( + This test validates the Create or Update Coverage Information cards or system actions received from the + CRD service, as per the specifications outlined in the [Da Vinci CRD Implementation Guide](https://hl7.org/fhir/us/davinci-crd/STU2/cards.html#create-or-update-coverage-information). + + - **Checking for Presence:** + The test first checks if any Create or Update Coverage Information cards or system actions are present in + the returned valid cards or valid system actions. + - **For cards**: it ensures there are cards with a `suggestions` array containing a single suggestion, + and the `actions` array of that suggestion has one `create` or `update` action for the `Coverage` resource. + - **For system actions**: it checks for the presence of `create` or `update` actions for the `Coverage` + resource. + + - **Validating:** + If any Create or Update Coverage Information cards or system actions are found, each `Coverage` resource is + validated against the base FHIR Coverage resource. + + If no Create or Update Coverage Information cards or system actions are received, the test is skipped. + ) + optional + input :valid_cards_with_suggestions, :valid_system_actions + + def hook_name + config.options[:hook_name] + end + + def coverage_actions(actions) + return [] if actions.nil? + + valid_types = ['create', 'update'] + actions.filter do |action| + valid_types.include?(action['type']) && action_resource_type_check(action, ['Coverage']) + end + end + + def create_or_update_coverage_info_card?(card) + card['suggestions'].one? && coverage_actions(card['suggestions'].first['actions']).one? + end + + run do + parsed_cards = parse_json(valid_cards_with_suggestions) + parsed_actions = parse_json(valid_system_actions) + + create_or_update_coverage_info_cards = parsed_cards.filter { |card| create_or_update_coverage_info_card?(card) } + create_or_update_coverage_info_actions = coverage_actions(parsed_actions) + + skip_msg = "#{hook_name} hook response does not contain any Create or Update Coverage Information " \ + 'cards or system actions.' + skip_if create_or_update_coverage_info_cards.blank? && create_or_update_coverage_info_actions.blank?, skip_msg + + actions_check(create_or_update_coverage_info_actions) if create_or_update_coverage_info_actions.present? + + if create_or_update_coverage_info_cards.present? + create_or_update_coverage_info_cards.each do |card| + actions = card['suggestions'].first['actions'] + actions_check(coverage_actions(actions)) + end + end + + no_error_validation('Some Create or Update Coverage Information received are not valid.') + end + end +end diff --git a/lib/davinci_crd_test_kit/server_tests/discovery_endpoint_test.rb b/lib/davinci_crd_test_kit/server_tests/discovery_endpoint_test.rb new file mode 100644 index 0000000..4f36bc4 --- /dev/null +++ b/lib/davinci_crd_test_kit/server_tests/discovery_endpoint_test.rb @@ -0,0 +1,88 @@ +module DaVinciCRDTestKit + class DiscoveryEndpointTest < Inferno::Test + title 'Server returns a discovery response' + id :crd_discovery_endpoint_test + description %( + A CDS Service provider must expose its discovery endpoint at `{baseURL}/cds-services` + as specified in the [CDS Hooks Specification](https://cds-hooks.hl7.org/2.0/#discovery). + + This test checks that the server responds to a GET request at the following endpoint: + + `GET {baseURL}/cds-services` + + It does this by checking that the server responds with an HTTP OK 200 status code + and that the body of the response is a valid JSON object. This test does not + inspect the structure and content of the response body to see if it contains the required information. + It only checks to see if the RESTful interaction is supported and returns a valid JSON object. + ) + + input_order :base_url, :authentication_required, :encryption_method, :jwks_kid + input :base_url + input :authentication_required, + title: 'Discovery endpoint requires authentication?', + type: 'radio', + default: 'no', + options: { + list_options: [ + { + label: 'No', + value: 'no' + }, + { + label: 'Yes', + value: 'yes' + } + ] + } + input :encryption_method, + title: 'JWT Signing Algorithm', + description: <<~DESCRIPTION, + CDS Hooks recommends ES384 and RS384 for JWT signature verification. + Select which method to use. + DESCRIPTION + type: 'radio', + default: 'ES384', + options: { + list_options: [ + { + label: 'ES384', + value: 'ES384' + }, + { + label: 'RS384', + value: 'RS384' + } + ] + } + input :jwks_kid, + title: 'CDS Services JWKS kid', + description: <<~DESCRIPTION, + The key ID of the JWKS private key to use for signing the JWTs when invoking a CDS service endpoint + requiring authentication. + Defaults to the first JWK in the list if no kid is supplied. + DESCRIPTION + optional: true + output :cds_services + + run do + discovery_url = "#{base_url.chomp('/')}/cds-services" + headers = { 'Accept' => 'application/json' } + + if authentication_required == 'yes' + token = JwtHelper.build( + aud: discovery_url, + iss: inferno_base_url, + jku: "#{inferno_base_url}/jwks.json", + kid: jwks_kid, + encryption_method: + ) + headers['Authorization'] = "Bearer #{token}" + end + get(discovery_url, headers:) + assert_response_status(200) + assert_valid_json(request.response_body) + + output cds_services: request.response_body + end + end +end diff --git a/lib/davinci_crd_test_kit/server_tests/discovery_services_validation_test.rb b/lib/davinci_crd_test_kit/server_tests/discovery_services_validation_test.rb new file mode 100644 index 0000000..baa3be0 --- /dev/null +++ b/lib/davinci_crd_test_kit/server_tests/discovery_services_validation_test.rb @@ -0,0 +1,65 @@ +require_relative '../test_helper' + +module DaVinciCRDTestKit + class DiscoveryServicesValidationTest < Inferno::Test + include DaVinciCRDTestKit::TestHelper + + title 'Discovery response contains valid services' + id :crd_discovery_services_validation + description %( + As per the [CDS Hooks Spec](https://cds-hooks.hl7.org/2.0/#response), + the response to the discovery endpoint SHALL be an object containing + a list of CDS services. If your CDS server hosts no CDS services, + the discovery endpoint should return a 200 HTTP response with + an empty array of services. + + Each CDS service must contain the following required fields: + `hook`, `description`, and `id`. + + This test checks for the presence of the required fields and + validates that they are of the correct type. + + The test will be skipped if the server hosts no CDS services. + ) + + input :cds_services + output :appointment_book_service_ids, :encounter_start_service_ids, :encounter_discharge_service_ids, + :order_dispatch_service_ids, :order_select_service_ids, :order_sign_service_ids + + def required_fields + { + 'hook' => String, + 'description' => String, + 'id' => String + } + end + + run do + object = parse_json(cds_services) + assert object['services'], 'Discovery response did not contain `services`' + + services = object['services'] + assert services.is_a?(Array), 'Services field of the CDS Discovery response object is not an array.' + skip_if services.empty?, 'Server hosts no CDS Services.' + + service_hooks_to_ids = services.each_with_object({}) do |service, hash| + hash[service['hook']] ||= [] + hash[service['hook']] << service['id'] if service['id'] + end + + output appointment_book_service_ids: service_hooks_to_ids['appointment-book']&.join(', '), + encounter_start_service_ids: service_hooks_to_ids['encounter-start']&.join(', '), + encounter_discharge_service_ids: service_hooks_to_ids['encounter-discharge']&.join(', '), + order_dispatch_service_ids: service_hooks_to_ids['order-dispatch']&.join(', '), + order_select_service_ids: service_hooks_to_ids['order-select']&.join(', '), + order_sign_service_ids: service_hooks_to_ids['order-sign']&.join(', ') + + services.each do |service| + required_fields.each do |field, type| + assert(service[field], "Service `#{service}` did not contain required field: `#{field}`") + assert(service[field].is_a?(type), "Service `#{service}`: field `#{field}` is not of type #{type}") + end + end + end + end +end diff --git a/lib/davinci_crd_test_kit/server_tests/external_reference_card_across_hooks_validation_test.rb b/lib/davinci_crd_test_kit/server_tests/external_reference_card_across_hooks_validation_test.rb new file mode 100644 index 0000000..ef3807d --- /dev/null +++ b/lib/davinci_crd_test_kit/server_tests/external_reference_card_across_hooks_validation_test.rb @@ -0,0 +1,28 @@ +require_relative '../test_helper' + +module DaVinciCRDTestKit + class ExternalReferenceCardAcrossHooksValidationTest < Inferno::Test + include DaVinciCRDTestKit::TestHelper + + title 'Valid External Reference cards received across all hooks' + id :crd_external_reference_card_across_hooks_validation + description %( + This test verifies the presence of valid External Reference returned by CRD services across all hooks invoked. + As per the [Da Vinci CRD Implementation Guide](https://hl7.org/fhir/us/davinci-crd/STU2/cards.html#external-reference), + External Reference cards must contain links with the type set to `absolute`. + This test checks for the presence of any External Reference cards by verifying: + - The presence of a `links` array within each card. + - That every link in the `links` array of a card is of type `absolute`. + + The test will be skipped if no valid External Reference cards are returned across all hooks. + ) + + run do + verify_at_least_one_test_passes( + self.class.parent.parent.groups, + 'crd_external_reference_card_validation', + 'None of the hooks invoked returned an External Reference card.' + ) + end + end +end diff --git a/lib/davinci_crd_test_kit/server_tests/external_reference_card_validation_test.rb b/lib/davinci_crd_test_kit/server_tests/external_reference_card_validation_test.rb new file mode 100644 index 0000000..38b7667 --- /dev/null +++ b/lib/davinci_crd_test_kit/server_tests/external_reference_card_validation_test.rb @@ -0,0 +1,36 @@ +require_relative '../test_helper' + +module DaVinciCRDTestKit + class ExternalReferenceCardValidationTest < Inferno::Test + include DaVinciCRDTestKit::TestHelper + + title 'Valid External Reference cards received' + id :crd_external_reference_card_validation + description %( + This test verifies the presence of valid External Reference cards within the list of valid cards + returned by the CRD service. + As per the [Da Vinci CRD Implementation Guide](https://hl7.org/fhir/us/davinci-crd/STU2/cards.html#external-reference), + External Reference cards must contain links with the type set to `absolute`. + This test checks for the presence of any External Reference cards by verifying: + - The presence of a `links` array within each card. + - That every link in the `links` array of a card is of type `absolute`. + ) + + input :valid_cards_with_links + optional + + def hook_name + config.options[:hook_name] + end + + run do + parsed_cards = parse_json(valid_cards_with_links) + external_reference_cards = parsed_cards.select do |card| + links = card['links'] + links.present? && links.all? { |link| link['type'] == 'absolute' } + end + + assert external_reference_cards.present?, "#{hook_name} hook response did not contain an External Reference card." + end + end +end diff --git a/lib/davinci_crd_test_kit/server_tests/form_completion_response_validation_test.rb b/lib/davinci_crd_test_kit/server_tests/form_completion_response_validation_test.rb new file mode 100644 index 0000000..e90e484 --- /dev/null +++ b/lib/davinci_crd_test_kit/server_tests/form_completion_response_validation_test.rb @@ -0,0 +1,79 @@ +require_relative '../test_helper' +require_relative '../suggestion_actions_validation' + +module DaVinciCRDTestKit + class FormCompletionResponseValidationTest < Inferno::Test + include DaVinciCRDTestKit::TestHelper + include DaVinciCRDTestKit::SuggestionActionsValidation + + title 'Valid Request Form Completion cards or system actions received' + id :crd_request_form_completion_response_validation + description %( + This test validates the Request Form Completion cards or system actions received from the CRD service, + as per the specifications outlined in the [Da Vinci CRD Implementation Guide](https://hl7.org/fhir/us/davinci-crd/STU2/cards.html#request-form-completion). + + - **Checking for Presence:** + The test begins by verifying whether any Request Form Completion cards or system actions are present. + - **For cards:** It ensures that there are cards with `suggestions` containing `create` actions + for the `Task` resource, specifically: + - The `Task` must have a `code` of `complete-questionnaire`. + - The `Task` should include an input of type `text` (`Task.input.type.text`) labeled as `questionnaire` + and associated with a valid canonical URL (`Task.input.valueCanonical`). + - **For system actions:** It checks for the presence of `create` actions for the `Task` resource with + the characteristics described above. + + - **Validating:** + If any Request Form Completion cards or system actions are found, the test proceeds to validate them. + Each `Task` resource is validated against the [CRD Questionnaire Task profile](http://hl7.org/fhir/us/davinci-crd/StructureDefinition/profile-taskquestionnaire). + + If no Request Form Completion cards or system actions are received, the test is skipped. + ) + optional + input :valid_cards_with_suggestions, :valid_system_actions + + def hook_name + config.options[:hook_name] + end + + def task_actions(actions) + actions&.select { |action| action['type'] == 'create' && action_resource_type_check(action, ['Task']) } + end + + def task_questionnaire?(task_action) + task = FHIR.from_contents(task_action['resource'].to_json) + task.code.coding.any? { |code| code.code == 'complete-questionnaire' } && + task.input.any? { |input| input.type.text == 'questionnaire' && valid_url?(input.valueCanonical) } + end + + def request_form_completion_card?(card) + card['suggestions'].all? do |suggestion| + actions = suggestion['actions'] + task_actions = task_actions(actions) + task_actions.present? && task_actions.all? { |action| task_questionnaire?(action) } + end + end + + run do + parsed_cards = parse_json(valid_cards_with_suggestions) + parsed_actions = parse_json(valid_system_actions) + + form_completion_cards = parsed_cards.filter { |card| request_form_completion_card?(card) } + form_completion_actions = task_actions(parsed_actions).select { |action| task_questionnaire?(action) } + + skip_if form_completion_cards.blank? && form_completion_actions.blank?, + "#{hook_name} hook response does not contain any Request Form Completion cards or system actions." + + actions_check(form_completion_actions) if form_completion_actions.present? + + if form_completion_cards.present? + form_completion_cards.each do |card| + card['suggestions'].each do |suggestion| + actions_check(task_actions(suggestion['actions'])) + end + end + end + + no_error_validation('Some Request Form Completion received are not valid.') + end + end +end diff --git a/lib/davinci_crd_test_kit/server_tests/instructions_card_received_across_hooks_test.rb b/lib/davinci_crd_test_kit/server_tests/instructions_card_received_across_hooks_test.rb new file mode 100644 index 0000000..375843f --- /dev/null +++ b/lib/davinci_crd_test_kit/server_tests/instructions_card_received_across_hooks_test.rb @@ -0,0 +1,25 @@ +require_relative '../test_helper' + +module DaVinciCRDTestKit + class InstructionsCardReceivedAcrossHooksTest < Inferno::Test + include DaVinciCRDTestKit::TestHelper + + title 'Valid Instructions cards received across all hooks' + id :crd_valid_instructions_card_received_across_hooks + description %( + This test validates that a valid [Instructions card](https://hl7.org/fhir/us/davinci-crd/STU2/cards.html#instructions) + was received across all hooks responses. + + The test will be skipped if no valid Instructions cards are returned across all hooks. + ) + + run do + verify_at_least_one_test_passes( + self.class.parent.parent.groups, + 'crd_valid_instructions_card_received', + 'None of the hooks invoked returned a valid Instructions card.', + 'across_hooks' + ) + end + end +end diff --git a/lib/davinci_crd_test_kit/server_tests/instructions_card_received_test.rb b/lib/davinci_crd_test_kit/server_tests/instructions_card_received_test.rb new file mode 100644 index 0000000..4c77bc5 --- /dev/null +++ b/lib/davinci_crd_test_kit/server_tests/instructions_card_received_test.rb @@ -0,0 +1,28 @@ +require_relative '../test_helper' + +module DaVinciCRDTestKit + class InstructionsCardReceivedTest < Inferno::Test + include DaVinciCRDTestKit::TestHelper + + title 'Valid Instructions cards received' + id :crd_valid_instructions_card_received + description %( + This test validates that an [Instructions](https://hl7.org/fhir/us/davinci-crd/STU2/cards.html#instructions) + card was received. It does so by: + - Checking for the presence of a valid card that does not contain the `links` field and the `suggestions` field. + ) + + input :valid_cards + optional + + def hook_name + config.options[:hook_name] + end + + run do + parsed_cards = parse_json(valid_cards) + instructions_card = parsed_cards.find { |card| card['links'].blank? && card['suggestions'].blank? } + assert instructions_card, 'Hook response did not contain an Instructions card.' + end + end +end diff --git a/lib/davinci_crd_test_kit/server_tests/launch_smart_app_card_validation_test.rb b/lib/davinci_crd_test_kit/server_tests/launch_smart_app_card_validation_test.rb new file mode 100644 index 0000000..1181dd1 --- /dev/null +++ b/lib/davinci_crd_test_kit/server_tests/launch_smart_app_card_validation_test.rb @@ -0,0 +1,38 @@ +require_relative '../test_helper' + +module DaVinciCRDTestKit + class LaunchSmartAppCardValidationTest < Inferno::Test + include DaVinciCRDTestKit::TestHelper + + title 'Valid Launch SMART Application cards received' + id :crd_launch_smart_app_card_validation + description %( + This test verifies the presence of valid Launch SMART Application cards within the list of valid cards + returned by the CRD service. + As per the [Da Vinci CRD Implementation Guide](https://hl7.org/fhir/us/davinci-crd/STU2/cards.html#launch-smart-application), + Launch SMART Application cards must contain links with the type set to `smart`. + This test checks for the presence of any Launch SMART Application cards by verifying: + - The existence of a `links` array within each card. + - That every link in the `links` array of a card is of type `smart`. + + The test will be skipped if no Launch SMART Application cards are found within the returned valid cards. + ) + + optional + input :valid_cards_with_links + + def hook_name + config.options[:hook_name] + end + + run do + parsed_cards = parse_json(valid_cards_with_links) + external_reference_cards = parsed_cards.select do |card| + links = card['links'] + links.present? && links.all? { |link| link['type'] == 'smart' } + end + + skip_if external_reference_cards.blank?, "#{hook_name} hook response does not contain any Launch SMART App cards." + end + end +end diff --git a/lib/davinci_crd_test_kit/server_tests/propose_alternate_request_card_validation_test.rb b/lib/davinci_crd_test_kit/server_tests/propose_alternate_request_card_validation_test.rb new file mode 100644 index 0000000..997b590 --- /dev/null +++ b/lib/davinci_crd_test_kit/server_tests/propose_alternate_request_card_validation_test.rb @@ -0,0 +1,65 @@ +require_relative '../test_helper' +require_relative '../suggestion_actions_validation' + +module DaVinciCRDTestKit + class ProposeAlternateRequestCardValidationTest < Inferno::Test + include DaVinciCRDTestKit::TestHelper + include DaVinciCRDTestKit::SuggestionActionsValidation + + title 'Valid Propose Alternate Request cards received' + id :crd_propose_alternate_request_card_validation + description %( + This test validates that all [Propose Alternate Request](https://hl7.org/fhir/us/davinci-crd/STU2/cards.html#propose-alternate-request) + cards received are valid. It checks for the presence of a card's suggestion + with a single action with `Action.type` of `update` or a card with at least + two actions, one with `Action.type` of `delete` and the other with + `Action.type` of `create`. + ) + optional + input :valid_cards_with_suggestions, :contexts + + EXPECTED_RESOURCE_TYPES = %w[ + Device DeviceRequest Encounter Medication + MedicationRequest NutritionOrder ServiceRequest + VisionPrescription + ].freeze + + def hook_name + config.options[:hook_name] + end + + def check_action_type(actions, action_type) + actions&.any? do |action| + action['type'] == action_type && action_resource_type_check(action, EXPECTED_RESOURCE_TYPES) + end + end + + def propose_alternate_request_card?(card) + card['suggestions'].any? do |suggestion| + actions = suggestion['actions'] + has_update = check_action_type(actions, 'update') + has_delete = check_action_type(actions, 'delete') + has_create = check_action_type(actions, 'create') + + has_update || (has_delete && has_create) + end + end + + run do + parsed_cards = parse_json(valid_cards_with_suggestions) + parsed_contexts = parse_json(contexts) + proposed_alternate_cards = parsed_cards.filter { |card| propose_alternate_request_card?(card) } + + skip_if proposed_alternate_cards.blank?, + "#{hook_name} hook response does not contain a Propose Alternate Request card." + + proposed_alternate_cards.each do |card| + card['suggestions'].each do |suggestion| + actions_check(suggestion['actions'], parsed_contexts) + end + end + + no_error_validation('Some Proposed Alternate Request cards are not valid.') + end + end +end diff --git a/lib/davinci_crd_test_kit/server_tests/service_call_test.rb b/lib/davinci_crd_test_kit/server_tests/service_call_test.rb new file mode 100644 index 0000000..acb0fe5 --- /dev/null +++ b/lib/davinci_crd_test_kit/server_tests/service_call_test.rb @@ -0,0 +1,86 @@ +module DaVinciCRDTestKit + class ServiceCallTest < Inferno::Test + title 'Submit user-defined service requests' + id :crd_service_call_test + description %( + This test initiates POST request(s) to a specified CDS Service using the JSON body list provided by the user. + As indicated in the [CDS Hooks specification section on Calling a CDS Service](https://cds-hooks.hl7.org/2.0/#calling-a-cds-service), + the service endpoint is constructed by appending the individual service id to the CDS Service base URL, + following the format `{baseUrl}/cds-services/{service.id}`. + + If running this group only, the user will need to provide the `service.id` to call the specified service. + Otherwise, the `service.id` is derived from the CDS Services that are retrieved through a query to the + discovery endpoint. + + The test will be skipped if the CRD server does not host a CDS Service corresponding to the hook that + is being tested. + + The test is deemed successful if the CRD server returns a 200 HTTP response for all requests. + ) + input_order :base_url, :encryption_method, :jwks_kid + input :base_url, :service_ids + input :service_request_bodies, + optional: true, + type: 'textarea', + description: 'Provide a list of request bodies send multiple requests. e.g. [json_body_1, json_body_2]' + input :encryption_method, + title: 'JWT Signing Algorithm', + description: <<~DESCRIPTION, + CDS Hooks recommends ES384 and RS384 for JWT signature verification. + Select which method to use. + DESCRIPTION + type: 'radio', + options: { + list_options: [ + { + label: 'ES384', + value: 'ES384' + }, + { + label: 'RS384', + value: 'RS384' + } + ] + } + input :jwks_kid, + title: 'CDS Services JWKS kid', + description: <<~DESCRIPTION, + The key ID of the JWKS private key to use for signing the JWTs when invoking a CDS service endpoint + requiring authentication. + Defaults to the first JWK in the list if no kid is supplied. + DESCRIPTION + optional: true + + def hook_name + config.options[:hook_name] + end + + run do + discovery_url = "#{base_url.chomp('/')}/cds-services" + skip_if service_request_bodies.blank?, + 'Request body not provided, skipping test.' + assert_valid_json(service_request_bodies) + + payloads = [JSON.parse(service_request_bodies)].flatten + service_id = service_ids.split(', ').first.strip + service_endpoint = "#{discovery_url}/#{service_id}" + token = JwtHelper.build( + aud: service_endpoint, + iss: inferno_base_url, + jku: "#{inferno_base_url}/jwks.json", + kid: jwks_kid, + encryption_method: + ) + headers = { 'Content-type' => 'application/json', 'Authorization' => "Bearer #{token}" } + + payloads.each do |payload| + post(service_endpoint, body: payload.to_json, headers:, tags: [hook_name]) + end + + requests.each do |request| + assert_response_status(200, request:) + assert_valid_json(request.response_body) + end + end + end +end diff --git a/lib/davinci_crd_test_kit/server_tests/service_request_context_validation_test.rb b/lib/davinci_crd_test_kit/server_tests/service_request_context_validation_test.rb new file mode 100644 index 0000000..3b5457c --- /dev/null +++ b/lib/davinci_crd_test_kit/server_tests/service_request_context_validation_test.rb @@ -0,0 +1,30 @@ +require_relative '../server_hook_request_validation' +require_relative '../test_helper' + +module DaVinciCRDTestKit + class ServiceRequestContextValidationTest < Inferno::Test + include DaVinciCRDTestKit::ServerHookRequestValidation + include DaVinciCRDTestKit::TestHelper + + title 'All service requests contain valid context' + id :crd_service_request_context_validation + description %( + This test verifies that all service requests `context` field is valid and contains all the + required fields. + ) + input :contexts + + def hook_name + config.options[:hook_name] + end + + run do + parsed_contexts = parse_json(contexts) + parsed_contexts.each do |context| + hook_request_context_check(context, hook_name) + end + + no_error_validation('Some contexts are not valid.') + end + end +end diff --git a/lib/davinci_crd_test_kit/server_tests/service_request_optional_fields_validation_test.rb b/lib/davinci_crd_test_kit/server_tests/service_request_optional_fields_validation_test.rb new file mode 100644 index 0000000..def6993 --- /dev/null +++ b/lib/davinci_crd_test_kit/server_tests/service_request_optional_fields_validation_test.rb @@ -0,0 +1,41 @@ +require_relative '../server_hook_request_validation' + +module DaVinciCRDTestKit + class ServiceRequestOptionalFieldsValidationTest < Inferno::Test + include DaVinciCRDTestKit::ServerHookRequestValidation + + title 'All service requests contain optional fields' + id :crd_service_request_optional_fields_validation + description %( + This optional test reviews the user-submitted CRD service requests for the presence of optional fields: + `fhirAuthorization` and `prefetch`. + + The test will not fail if these optional fields are missing from a service request; instead, it generates an + informational message. + ) + optional + + def hook_name + config.options[:hook_name] + end + + run do + load_tagged_requests(hook_name) + skip_if requests.empty?, "No #{hook_name} request was made in a previous test as expected." + + error_messages = [] + requests.each_with_index do |request, index| + assert_valid_json(request.request_body) + request_body = JSON.parse(request.request_body) + hook_request_optional_fields_check(request_body) + rescue Inferno::Exceptions::AssertionException => e + error_messages << "Request #{index + 1}: #{e.message}" + end + + error_messages.each do |msg| + messages << { type: 'error', message: msg } + end + assert error_messages.empty?, 'Some service requests have invalid optional fields.' + end + end +end diff --git a/lib/davinci_crd_test_kit/server_tests/service_request_required_fields_validation_test.rb b/lib/davinci_crd_test_kit/server_tests/service_request_required_fields_validation_test.rb new file mode 100644 index 0000000..ddc2fc1 --- /dev/null +++ b/lib/davinci_crd_test_kit/server_tests/service_request_required_fields_validation_test.rb @@ -0,0 +1,43 @@ +require_relative '../server_hook_request_validation' +module DaVinciCRDTestKit + class ServiceRequestRequiredFieldsValidationTest < Inferno::Test + include DaVinciCRDTestKit::ServerHookRequestValidation + + title 'All service requests contain required fields' + id :crd_service_request_required_fields_validation + description %( + This test validates all CRD service requests provided by the user, ensuring each includes all required fields + specified in the [CDS Hooks spec section on Calling a CDS Service](https://cds-hooks.hl7.org/2.0/#calling-a-cds-service): + `hook`, `hookInstance`, and `context`. Furthermore, the test checks for the conditional presence of the + `fhirServer` field if `fhirAuthorization` is included. + ) + output :contexts + + def hook_name + config.options[:hook_name] + end + + run do + load_tagged_requests(hook_name) + skip_if requests.empty?, "No #{hook_name} request was made in a previous test as expected." + + error_messages = [] + contexts = [] + requests.each_with_index do |request, index| + assert_valid_json(request.request_body) + request_body = JSON.parse(request.request_body) + contexts << request_body['context'] if request_body['context'].is_a?(Hash) + hook_request_required_fields_check(request_body, hook_name) + rescue Inferno::Exceptions::AssertionException => e + error_messages << "Request #{index + 1}: #{e.message}" + end + + output contexts: contexts.to_json + + error_messages.each do |msg| + messages << { type: 'error', message: msg } + end + assert error_messages.empty?, 'Some service requests made are not valid.' + end + end +end diff --git a/lib/davinci_crd_test_kit/server_tests/service_response_validation_test.rb b/lib/davinci_crd_test_kit/server_tests/service_response_validation_test.rb new file mode 100644 index 0000000..4997e23 --- /dev/null +++ b/lib/davinci_crd_test_kit/server_tests/service_response_validation_test.rb @@ -0,0 +1,82 @@ +require_relative '../cards_validation' + +module DaVinciCRDTestKit + class ServiceResponseValidationTest < Inferno::Test + include DaVinciCRDTestKit::CardsValidation + + title 'All service responses contain valid cards and optional systemActions' + id :crd_service_response_validation + description %( + As per the [CDS Hooks spec section on CDS Service Response](https://cds-hooks.hl7.org/2.0/#cds-service-response), + a successful server's response to a service request must be a JSON object containing a `cards` array. + It must also contain a `systemActions` array for `appointment-book` and `order-sign` hook. + + Each card must contain the following required fields: `summary`, `indicator`, and `source`. + The required fields must have a valid data structure. + ) + output :valid_cards, :valid_system_actions + + SYSTEM_ACTIONS_HOOK_NAMES = ['appointment-book', 'order-sign'].freeze + + def hook_name + config.options[:hook_name] + end + + def valid_cards + @valid_cards ||= [] + end + + def valid_system_actions + @valid_system_actions ||= [] + end + + def system_actions_check(system_actions) + system_actions.each do |action| + current_error_count = messages.count { |msg| msg[:type] == 'error' } + action_fields_validation(action) + valid_system_actions << action if current_error_count == messages.count { |msg| msg[:type] == 'error' } + end + end + + def perform_system_actions_validation(system_actions, response_index) + if SYSTEM_ACTIONS_HOOK_NAMES.include?(hook_name) && system_actions.nil? + msg = "Server response #{response_index + 1} did not have `systemActions` field." \ + "Must be present for #{hook_name}." + add_message('error', msg) + end + return if system_actions.nil? + + unless system_actions.is_a?(Array) + add_message('error', "`systemActions` of server response #{response_index + 1} is not an array.") + return + end + system_actions_check(system_actions) + end + + run do + load_tagged_requests(hook_name) + skip_if requests.blank?, "No #{hook_name} request was made in a previous test as expected." + successful_requests = requests.select { |request| request.status == 200 } + skip_if successful_requests.empty?, 'All service requests were unsuccessful.' + + info do + unsuncessful_count = (requests - successful_requests).length + assert unsuncessful_count.zero?, "#{unsuncessful_count} out of #{requests.length} requests were unsuccessful" + end + + successful_requests.each_with_index do |request, index| + service_response = JSON.parse(request.response_body) + perform_cards_validation(service_response['cards'], index) + + perform_system_actions_validation(service_response['systemActions'], index) + rescue JSON::ParserError + add_message('error', "Invalid JSON: server response #{index + 1} is not a valid JSON.") + end + + output valid_system_actions: valid_system_actions.to_json + output valid_cards: valid_cards.to_json + + no_error_validation('Some service responses are not valid. Check messages for issues found.') + end + end +end diff --git a/lib/davinci_crd_test_kit/suggestion_actions_validation.rb b/lib/davinci_crd_test_kit/suggestion_actions_validation.rb new file mode 100644 index 0000000..4ebb9f9 --- /dev/null +++ b/lib/davinci_crd_test_kit/suggestion_actions_validation.rb @@ -0,0 +1,123 @@ +require_relative 'server_hook_request_validation' +module DaVinciCRDTestKit + module SuggestionActionsValidation + include DaVinciCRDTestKit::ServerHookRequestValidation + + def action_required_fields + { 'type' => String, 'description' => String } + end + + def action_fields_validation(action) + action_required_fields.each do |field, type| + validate_presence_and_type(action, field, type, 'Action') + end + + action_type_field_validation(action) + end + + def action_type_field_validation(action) + return unless action['type'] + + allowed_types = ['create', 'update', 'delete'] + type = action['type'] + unless allowed_types.include?(type) + error_msg = "Action type value `#{type}` is not allowed. Allowed values: #{allowed_types.to_sentence}. " \ + "In Action `#{action}`" + add_message('error', error_msg) + return + end + + if ['create', 'update'].include?(type) + action_resource_field_validation(action, type) + else + action_resource_id_field_validation(action) + end + end + + def action_resource_field_validation(action, type) + unless action['resource'] + add_message('error', "`Action.resource` must be present for `#{type}` actions: `#{action}`.") + return + end + + resource = FHIR.from_contents(action['resource'].to_json) + return if resource + + add_message('error', "`Action.resource` must be a FHIR resource: `#{action}`.") + end + + def action_resource_id_field_validation(action) + validate_presence_and_type(action, 'resourceId', Array, '`delete` Action') + return unless action['resourceId'].is_a?(Array) + + action['resourceId'].each do |ref| + resource_reference_check(ref, 'Action.resourceId item') + end + end + + def draft_orders_bundle_entry_refs(contexts) + @draft_orders_bundle_entry_refs ||= contexts.flat_map do |context| + draft_orders_bundle = parse_fhir_bundle_from_context('draftOrders', context) + draft_orders_bundle.entry.map { |entry| "#{entry.resource.resourceType}/#{entry.resource.id}" } + end + end + + def action_resource_type_check(action, expected_resource_types) + resource_types = if ['create', 'update'].include?(action['type']) + [FHIR.from_contents(action['resource'].to_json).resourceType] + else + action['resourceId'].map { |ref| ref.split('/').first } + end + resource_types.all? { |resource_type| expected_resource_types.include?(resource_type) } + end + + def extract_resource_types_by_action(actions, action_type) + actions.each_with_object([]) do |act, resource_types| + resource_types << act['resource']['resourceType'] if act['type'] == action_type + end + end + + def actions_check(actions, contexts = nil) + create_actions_resource_types = extract_resource_types_by_action(actions, 'create') + + actions.each do |action| + case action['type'] + when 'create', 'update' + create_or_update_action_check(action, contexts) + when 'delete' + delete_action_check(action, create_actions_resource_types, contexts) + end + end + end + + def create_or_update_action_check(action, contexts) + resource = FHIR.from_contents(action['resource'].to_json) + resource_is_valid?(resource:, profile_url: structure_definition_map[resource.resourceType]) + return unless action['type'] == 'update' && contexts + + ref = "#{resource.resourceType}/#{resource.id}" + return if draft_orders_bundle_entry_refs(contexts).include?(ref) + + error_msg = "Resource being updated must be from the `draftOrders` entry. #{ref} is not in the " \ + "`context.drafOrders` of the submitted requests. In Action `#{action}`" + add_message('error', error_msg) + end + + def delete_action_check(action, create_actions_resource_types, contexts) + action['resourceId'].each do |ref| + unless draft_orders_bundle_entry_refs(contexts).include?(ref) + error_msg = '`Action.resourceId` must reference FHIR resource from the `draftOrders` entry. ' \ + "#{ref} is not in `draftOrders`. In Action `#{action}`" + add_message('error', error_msg) + next + end + + resource_type = ref.split('/').first + next if create_actions_resource_types.include?(resource_type) + + error_msg = "There's no `create` action for the proposed order being deleted: `#{ref}`. In Action `#{action}`" + add_message('error', error_msg) + end + end + end +end diff --git a/lib/davinci_crd_test_kit/tags.rb b/lib/davinci_crd_test_kit/tags.rb new file mode 100644 index 0000000..0ce12ae --- /dev/null +++ b/lib/davinci_crd_test_kit/tags.rb @@ -0,0 +1,8 @@ +module DaVinciCRDTestKit + APPOINTMENT_BOOK_TAG = 'crd_appointment_book'.freeze + ENCOUNTER_START_TAG = 'crd_encounter_start'.freeze + ENCOUNTER_DISCHARGE_TAG = 'crd_encounter_discharge'.freeze + ORDER_DISPATCH_TAG = 'crd_order_dispatch'.freeze + ORDER_SELECT_TAG = 'crd_order_select'.freeze + ORDER_SIGN_TAG = 'crd_order_sign'.freeze +end diff --git a/lib/davinci_crd_test_kit/test_helper.rb b/lib/davinci_crd_test_kit/test_helper.rb new file mode 100644 index 0000000..a78273f --- /dev/null +++ b/lib/davinci_crd_test_kit/test_helper.rb @@ -0,0 +1,23 @@ +module DaVinciCRDTestKit + module TestHelper + def parse_json(input) + assert_valid_json(input) + JSON.parse(input) + end + + def verify_at_least_one_test_passes(test_groups, id_pattern, error_message, id_exclude_pattern = nil) + runnables = test_groups.map do |group| + group.tests.find do |test| + test.id.include?(id_pattern) && (!id_exclude_pattern || !test.id.include?(id_exclude_pattern)) + end + end.compact + + results_repo = Inferno::Repositories::Results.new + results = results_repo.current_results_for_test_session_and_runnables(test_session_id, runnables) + + pass_if(results.any? { |result| result.result == 'pass' }) + + skip error_message + end + end +end diff --git a/lib/davinci_crd_test_kit/urls.rb b/lib/davinci_crd_test_kit/urls.rb new file mode 100644 index 0000000..799c9be --- /dev/null +++ b/lib/davinci_crd_test_kit/urls.rb @@ -0,0 +1,52 @@ +module DaVinciCRDTestKit + APPOINTMENT_BOOK_PATH = '/cds-services/appointment-book-service'.freeze + ENCOUNTER_START_PATH = '/cds-services/encounter-start-service'.freeze + ENCOUNTER_DISCHARGE_PATH = '/cds-services/encounter-discharge-service'.freeze + ORDER_DISPATCH_PATH = '/cds-services/order-dispatch-service'.freeze + ORDER_SELECT_PATH = '/cds-services/order-select-service'.freeze + ORDER_SIGN_PATH = '/cds-services/order-sign-service'.freeze + RESUME_PASS_PATH = '/resume_pass'.freeze + RESUME_FAIL_PATH = '/resume_fail'.freeze + + module URLs + def base_url + @base_url ||= "#{Inferno::Application['base_url']}/custom/#{suite_id}" + end + + def appointment_book_url + @appointment_book_url ||= base_url + APPOINTMENT_BOOK_PATH + end + + def encounter_start_url + @encounter_start_url ||= base_url + ENCOUNTER_START_PATH + end + + def encounter_discharge_url + @encounter_discharge_url ||= base_url + ENCOUNTER_DISCHARGE_PATH + end + + def order_dispatch_url + @order_dispatch_url ||= base_url + ORDER_DISPATCH_PATH + end + + def order_select_url + @order_select_url ||= base_url + ORDER_SELECT_PATH + end + + def order_sign_url + @order_sign_url ||= base_url + ORDER_SIGN_PATH + end + + def resume_pass_url + @resume_pass_url ||= base_url + RESUME_PASS_PATH + end + + def resume_fail_url + @resume_fail_url ||= base_url + RESUME_FAIL_PATH + end + + def suite_id + self.class.suite.id + end + end +end diff --git a/lib/davinci_crd_test_kit/version.rb b/lib/davinci_crd_test_kit/version.rb new file mode 100644 index 0000000..1d6e095 --- /dev/null +++ b/lib/davinci_crd_test_kit/version.rb @@ -0,0 +1,3 @@ +module DaVinciCRDTestKit + VERSION = '0.9.0'.freeze +end diff --git a/resources/crd_transaction_bundle.json b/resources/crd_transaction_bundle.json new file mode 100644 index 0000000..f3d8e64 --- /dev/null +++ b/resources/crd_transaction_bundle.json @@ -0,0 +1,6094 @@ +{ + "resourceType": "Bundle", + "type": "transaction", + "entry": [ + { + "fullUrl": "https://inferno-qa.healthit.gov/reference-server/r4/Patient/pat014", + "resource": { + "resourceType": "Patient", + "id": "pat014", + "meta": { + "versionId": "1", + "lastUpdated": "2024-05-13T18:47:32.366+00:00", + "source": "#dTLC55ZSUlQu76RV" + }, + "text": { + "status": "generated", + "div": "
Theodor Alan Roosevelt ROOSEVELT
Identifier0M846129001NF
Address7525 Colshire Dr
McLean VA
Date of birth04 July 1946
" + }, + "identifier": [ + { + "use": "usual", + "type": { + "coding": [ + { + "system": "http://terminology.hl7.org/CodeSystem/v2-0203", + "code": "MR" + } + ], + "text": "Medical Record Number" + }, + "system": "http://hl7.org/fhir/sid/us-medicare", + "value": "0M846129001NF" + } + ], + "name": [ + { + "use": "official", + "family": "Roosevelt", + "given": [ + "Theodor", + "Alan", + "Roosevelt" + ] + } + ], + "gender": "male", + "birthDate": "1946-07-04", + "address": [ + { + "use": "home", + "type": "both", + "line": [ + "7525 Colshire Dr" + ], + "city": "McLean", + "state": "VA", + "postalCode": "22102" + } + ] + }, + "request": { + "method": "PUT", + "url": "Patient/pat014" + } + }, + { + "fullUrl": "https://inferno-qa.healthit.gov/reference-server/r4/Coverage/cov014", + "resource": { + "resourceType": "Coverage", + "id": "cov014", + "meta": { + "versionId": "1", + "lastUpdated": "2024-05-13T18:47:32.445+00:00", + "source": "#yQxvq24DvI2jmo5n" + }, + "status": "active", + "subscriberId": "10A3D58WH1600", + "beneficiary": { + "reference": "Patient/pat014" + }, + "payor": [ + { + "reference": "Organization/org1234" + } + ], + "class": [ + { + "type": { + "coding": [ + { + "system": "http://hl7.org/fhir/coverage-class", + "code": "plan" + } + ] + }, + "value": "Medicare Part B" + } + ] + }, + "request": { + "method": "PUT", + "url": "Coverage/cov014" + } + }, + { + "fullUrl": "https://inferno-qa.healthit.gov/reference-server/r4/Condition/cond014a", + "resource": { + "resourceType": "Condition", + "id": "cond014a", + "meta": { + "versionId": "1", + "lastUpdated": "2024-05-13T18:47:32.510+00:00", + "source": "#qPRhKqlAgLNBum5S" + }, + "clinicalStatus": { + "coding": [ + { + "system": "http://terminology.hl7.org/CodeSystem/condition-clinical", + "code": "active", + "display": "Active" + } + ] + }, + "verificationStatus": { + "coding": [ + { + "system": "http://terminology.hl7.org/CodeSystem/condition-ver-status", + "code": "confirmed", + "display": "Confirmed" + } + ] + }, + "category": [ + { + "coding": [ + { + "system": "http://hl7.org/fhir/condition-category", + "code": "encounter-diagnosis", + "display": "Encounter Diagnosis" + } + ] + } + ], + "code": { + "coding": [ + { + "system": "http://hl7.org/fhir/sid/icd-10-cm", + "code": "J44.0", + "display": "Chronic obstructive pulmonary disease with acute lower respiratory infection" + } + ] + }, + "subject": { + "reference": "Patient/pat014" + } + }, + "request": { + "method": "PUT", + "url": "Condition/cond014a" + } + }, + { + "fullUrl": "https://inferno-qa.healthit.gov/reference-server/r4/Condition/cond014b", + "resource": { + "resourceType": "Condition", + "id": "cond014b", + "meta": { + "versionId": "1", + "lastUpdated": "2024-05-13T18:47:32.563+00:00", + "source": "#UDRXPRBmh2HWTbyk" + }, + "clinicalStatus": { + "coding": [ + { + "system": "http://terminology.hl7.org/CodeSystem/condition-clinical", + "code": "active", + "display": "Active" + } + ] + }, + "verificationStatus": { + "coding": [ + { + "system": "http://terminology.hl7.org/CodeSystem/condition-ver-status", + "code": "confirmed", + "display": "Confirmed" + } + ] + }, + "category": [ + { + "coding": [ + { + "system": "http://hl7.org/fhir/condition-category", + "code": "encounter-diagnosis", + "display": "Encounter Diagnosis" + } + ] + } + ], + "code": { + "coding": [ + { + "system": "http://hl7.org/fhir/sid/icd-10-cm", + "code": "R12", + "display": "Heartburn" + } + ] + }, + "subject": { + "reference": "Patient/pat014" + } + }, + "request": { + "method": "PUT", + "url": "Condition/cond014b" + } + }, + { + "fullUrl": "https://inferno-qa.healthit.gov/reference-server/r4/Condition/cond014c", + "resource": { + "resourceType": "Condition", + "id": "cond014c", + "meta": { + "versionId": "1", + "lastUpdated": "2024-05-13T18:47:32.641+00:00", + "source": "#Da9qr7X5xzN4tMxI" + }, + "clinicalStatus": { + "coding": [ + { + "system": "http://terminology.hl7.org/CodeSystem/condition-clinical", + "code": "active", + "display": "Active" + } + ] + }, + "verificationStatus": { + "coding": [ + { + "system": "http://terminology.hl7.org/CodeSystem/condition-ver-status", + "code": "confirmed", + "display": "Confirmed" + } + ] + }, + "category": [ + { + "coding": [ + { + "system": "http://hl7.org/fhir/condition-category", + "code": "encounter-diagnosis", + "display": "Encounter Diagnosis" + } + ] + } + ], + "code": { + "coding": [ + { + "system": "http://hl7.org/fhir/sid/icd-10-cm", + "code": "A69.21", + "display": "Meningitis due to Lyme disease" + } + ] + }, + "subject": { + "reference": "Patient/pat014" + } + }, + "request": { + "method": "PUT", + "url": "Condition/cond014c" + } + }, + { + "fullUrl": "https://inferno-qa.healthit.gov/reference-server/r4/Condition/cond014a-motor-neuron", + "resource": { + "resourceType": "Condition", + "id": "cond014a-motor-neuron", + "meta": { + "versionId": "1", + "lastUpdated": "2024-05-13T18:47:32.708+00:00", + "source": "#ChYHXD2n8JJKunKx" + }, + "clinicalStatus": { + "coding": [ + { + "system": "http://terminology.hl7.org/CodeSystem/condition-clinical", + "code": "active", + "display": "Active" + } + ] + }, + "verificationStatus": { + "coding": [ + { + "system": "http://terminology.hl7.org/CodeSystem/condition-ver-status", + "code": "confirmed", + "display": "Confirmed" + } + ] + }, + "category": [ + { + "coding": [ + { + "system": "http://hl7.org/fhir/condition-category", + "code": "encounter-diagnosis", + "display": "Encounter Diagnosis" + } + ] + } + ], + "code": { + "coding": [ + { + "system": "http://hl7.org/fhir/sid/icd-10-cm", + "code": "G12.2", + "display": "Motor neuron disease" + } + ] + }, + "subject": { + "reference": "Patient/pat014" + } + }, + "request": { + "method": "PUT", + "url": "Condition/cond014a-motor-neuron" + } + }, + { + "fullUrl": "https://inferno-qa.healthit.gov/reference-server/r4/Encounter/enc-pat014-cold", + "resource": { + "resourceType": "Encounter", + "id": "enc-pat014-cold", + "meta": { + "versionId": "1", + "lastUpdated": "2024-05-13T18:47:32.767+00:00", + "source": "#aEdNGk7di1mFG7ND" + }, + "status": "finished", + "class": { + "system": "http://terminology.hl7.org/CodeSystem/v3-ActCode", + "code": "AMB", + "display": "ambulatory" + }, + "type": [ + { + "coding": [ + { + "system": "http://snomed.info/sct", + "code": "185345009", + "display": "Encounter for symptom" + } + ] + } + ], + "priority": { + "coding": [ + { + "system": "http://snomed.info/sct", + "code": "103391001", + "display": "Urgent" + } + ] + }, + "subject": { + "reference": "Patient/pat014", + "display": "Teddy" + }, + "participant": [ + { + "individual": { + "reference": "Practitioner/pra-sstrange" + } + } + ], + "period": { + "start": "2020-02-14T10:40:10+01:00", + "end": "2020-02-14T12:40:10+01:00" + }, + "length": { + "value": 56, + "unit": "minutes", + "system": "http://unitsofmeasure.org", + "code": "min" + }, + "reasonCode": [ + { + "coding": [ + { + "system": "http://snomed.info/sct", + "code": "82272006", + "display": "Common cold (disorder)" + } + ] + } + ], + "diagnosis": [ + { + "condition": { + "reference": "Condition/cond014b", + "display": "The patient is treated for heartburn" + }, + "use": { + "coding": [ + { + "system": "http://terminology.hl7.org/CodeSystem/diagnosis-role", + "code": "AD", + "display": "Admission diagnosis" + } + ] + }, + "rank": 2 + }, + { + "condition": { + "reference": "Condition/cond014b", + "display": "The patient is treated for heartburn" + }, + "use": { + "coding": [ + { + "system": "http://terminology.hl7.org/CodeSystem/diagnosis-role", + "code": "CC", + "display": "Chief complaint" + } + ] + }, + "rank": 1 + } + ] + }, + "request": { + "method": "PUT", + "url": "Encounter/enc-pat014-cold" + } + }, + { + "fullUrl": "https://inferno-qa.healthit.gov/reference-server/r4/Encounter/enc-pat014", + "resource": { + "resourceType": "Encounter", + "id": "enc-pat014", + "meta": { + "versionId": "1", + "lastUpdated": "2024-05-13T18:47:32.847+00:00", + "source": "#CR5TwyaTFzLb9TGC" + }, + "status": "finished", + "class": { + "system": "http://terminology.hl7.org/CodeSystem/v3-ActCode", + "code": "AMB", + "display": "ambulatory" + }, + "type": [ + { + "coding": [ + { + "system": "http://snomed.info/sct", + "code": "185349003", + "display": "Encounter for check up" + } + ] + } + ], + "priority": { + "coding": [ + { + "system": "http://snomed.info/sct", + "code": "103391001", + "display": "Urgent" + } + ] + }, + "subject": { + "reference": "Patient/pat014", + "display": "Teddy" + }, + "participant": [ + { + "individual": { + "reference": "Practitioner/pra1234" + } + } + ], + "period": { + "start": "2020-01-15T12:40:10+01:00", + "end": "2020-01-15T13:40:10+01:00" + }, + "length": { + "value": 56, + "unit": "minutes", + "system": "http://unitsofmeasure.org", + "code": "min" + }, + "reasonCode": [ + { + "coding": [ + { + "system": "http://snomed.info/sct", + "code": "42343007", + "display": "Congestive heart failure (disorder)" + } + ] + } + ], + "diagnosis": [ + { + "condition": { + "reference": "Condition/cond014a", + "display": "Complications from infection on January 9th, 2020" + }, + "use": { + "coding": [ + { + "system": "http://terminology.hl7.org/CodeSystem/diagnosis-role", + "code": "AD", + "display": "Admission diagnosis" + } + ] + }, + "rank": 2 + }, + { + "condition": { + "reference": "Condition/cond014a", + "display": "The patient is treated for wheezing" + }, + "use": { + "coding": [ + { + "system": "http://terminology.hl7.org/CodeSystem/diagnosis-role", + "code": "CC", + "display": "Chief complaint" + } + ] + }, + "rank": 1 + } + ] + }, + "request": { + "method": "PUT", + "url": "Encounter/enc-pat014" + } + }, + { + "fullUrl": "https://inferno-qa.healthit.gov/reference-server/r4/Encounter/enc-pat014-heartfailure-old", + "resource": { + "resourceType": "Encounter", + "id": "enc-pat014-heartfailure-old", + "meta": { + "versionId": "1", + "lastUpdated": "2024-05-13T18:47:32.948+00:00", + "source": "#FV9NK1ai7wQUIPZw" + }, + "status": "finished", + "class": { + "system": "http://terminology.hl7.org/CodeSystem/v3-ActCode", + "code": "AMB", + "display": "ambulatory" + }, + "type": [ + { + "coding": [ + { + "system": "http://snomed.info/sct", + "code": "185349003", + "display": "Encounter for check up" + } + ] + } + ], + "priority": { + "coding": [ + { + "system": "http://snomed.info/sct", + "code": "103391001", + "display": "Urgent" + } + ] + }, + "subject": { + "reference": "Patient/pat014", + "display": "Teddy" + }, + "participant": [ + { + "individual": { + "reference": "Practitioner/pra1234" + } + } + ], + "period": { + "start": "2018-08-15T12:40:10+01:00", + "end": "2018-08-17T15:40:10+01:00" + }, + "length": { + "value": 2, + "unit": "days", + "system": "http://unitsofmeasure.org", + "code": "day" + }, + "reasonCode": [ + { + "coding": [ + { + "system": "http://snomed.info/sct", + "code": "42343007", + "display": "Congestive heart failure (disorder)" + } + ] + } + ], + "diagnosis": [ + { + "condition": { + "reference": "Condition/cond014a", + "display": "Complications from infection on January 9th, 2020" + }, + "use": { + "coding": [ + { + "system": "http://terminology.hl7.org/CodeSystem/diagnosis-role", + "code": "AD", + "display": "Admission diagnosis" + } + ] + }, + "rank": 2 + }, + { + "condition": { + "reference": "Condition/cond014a", + "display": "The patient is treated for wheezing" + }, + "use": { + "coding": [ + { + "system": "http://terminology.hl7.org/CodeSystem/diagnosis-role", + "code": "CC", + "display": "Chief complaint" + } + ] + }, + "rank": 1 + } + ] + }, + "request": { + "method": "PUT", + "url": "Encounter/enc-pat014-heartfailure-old" + } + }, + { + "fullUrl": "https://inferno-qa.healthit.gov/reference-server/r4/Observation/obs014", + "resource": { + "resourceType": "Observation", + "id": "obs014", + "meta": { + "versionId": "1", + "lastUpdated": "2024-05-13T18:47:33.018+00:00", + "source": "#x2N3G57AM15OyuGD" + }, + "status": "final", + "category": [ + { + "coding": [ + { + "system": "http://terminology.hl7.org/CodeSystem/observation-category", + "code": "laboratory", + "display": "Laboratory" + } + ], + "text": "Laboratory" + } + ], + "code": { + "coding": [ + { + "system": "http://loinc.org", + "code": "59408-5", + "display": "Oxygen saturation in Arterial blood by Pulse oximetry" + } + ] + }, + "subject": { + "reference": "Patient/pat014" + }, + "issued": "2019-03-21T15:35:10+01:00", + "valueQuantity": { + "value": 92, + "unit": "%", + "system": "http://unitsofmeasure.org", + "code": "%" + } + }, + "request": { + "method": "PUT", + "url": "Observation/obs014" + } + }, + { + "fullUrl": "https://inferno-qa.healthit.gov/reference-server/r4/Observation/obs014B", + "resource": { + "resourceType": "Observation", + "id": "obs014B", + "meta": { + "versionId": "1", + "lastUpdated": "2024-05-13T18:47:33.095+00:00", + "source": "#ZEcak5f5L15GUMLD" + }, + "status": "final", + "category": [ + { + "coding": [ + { + "system": "http://terminology.hl7.org/CodeSystem/observation-category", + "code": "laboratory", + "display": "Laboratory" + } + ], + "text": "Laboratory" + } + ], + "code": { + "coding": [ + { + "system": "http://loinc.org", + "code": "31100-1", + "display": "Hematocrit [Volume Fraction] of Blood by Impedance" + } + ] + }, + "subject": { + "reference": "Patient/pat014" + }, + "issued": "2020-04-06T15:40:10+01:00", + "valueQuantity": { + "value": 70, + "unit": "%", + "system": "http://unitsofmeasure.org", + "code": "%" + } + }, + "request": { + "method": "PUT", + "url": "Observation/obs014B" + } + }, + { + "fullUrl": "https://inferno-qa.healthit.gov/reference-server/r4/Observation/obs014BodyWeight", + "resource": { + "resourceType": "Observation", + "id": "obs014BodyWeight", + "meta": { + "versionId": "1", + "lastUpdated": "2024-05-13T18:47:33.196+00:00", + "source": "#D3glEiFpNwwjAsRA" + }, + "status": "final", + "category": [ + { + "coding": [ + { + "system": "http://terminology.hl7.org/CodeSystem/observation-category", + "code": "vital-signs", + "display": "Vital Signs" + } + ] + } + ], + "code": { + "coding": [ + { + "system": "http://loinc.org", + "code": "29463-7", + "display": "Body Weight" + } + ] + }, + "subject": { + "reference": "Patient/pat014" + }, + "issued": "2020-02-11T15:40:10+01:00", + "valueQuantity": { + "value": 175, + "unit": "[lb_av]", + "system": "http://unitsofmeasure.org", + "code": "[lb_av]" + } + }, + "request": { + "method": "PUT", + "url": "Observation/obs014BodyWeight" + } + }, + { + "fullUrl": "https://inferno-qa.healthit.gov/reference-server/r4/Observation/obs014BMI", + "resource": { + "resourceType": "Observation", + "id": "obs014BMI", + "meta": { + "versionId": "1", + "lastUpdated": "2024-05-13T18:47:33.260+00:00", + "source": "#bZUosIB8rg7rCaHy" + }, + "status": "final", + "category": [ + { + "coding": [ + { + "system": "http://terminology.hl7.org/CodeSystem/observation-category", + "code": "vital-signs", + "display": "Vital Signs" + } + ] + } + ], + "code": { + "coding": [ + { + "system": "http://loinc.org", + "code": "39156-5", + "display": "Body mass index (BMI) [Ratio]" + } + ] + }, + "subject": { + "reference": "Patient/pat014" + }, + "issued": "2020-02-11T15:40:10+01:00", + "valueQuantity": { + "value": 16.2, + "unit": "kg/m2", + "system": "http://unitsofmeasure.org", + "code": "kg/m2" + } + }, + "request": { + "method": "PUT", + "url": "Observation/obs014BMI" + } + }, + { + "fullUrl": "https://inferno-qa.healthit.gov/reference-server/r4/Observation/obs014-NeckCircumference", + "resource": { + "resourceType": "Observation", + "id": "obs014-NeckCircumference", + "meta": { + "versionId": "1", + "lastUpdated": "2024-05-13T18:47:33.356+00:00", + "source": "#P60OCBKKioL4tetv" + }, + "status": "final", + "category": [ + { + "coding": [ + { + "system": "http://terminology.hl7.org/CodeSystem/observation-category", + "code": "vital-signs", + "display": "Vital Signs" + } + ] + } + ], + "code": { + "coding": [ + { + "system": "http://loinc.org", + "code": "56074-8", + "display": "Circumference Neck" + } + ] + }, + "subject": { + "reference": "Patient/pat014" + }, + "issued": "2020-02-11T15:40:10+01:00", + "valueQuantity": { + "value": 51.2, + "unit": "cm", + "system": "http://unitsofmeasure.org", + "code": "cm" + } + }, + "request": { + "method": "PUT", + "url": "Observation/obs014-NeckCircumference" + } + }, + { + "fullUrl": "https://inferno-qa.healthit.gov/reference-server/r4/Observation/obs-pat014-pao2", + "resource": { + "resourceType": "Observation", + "id": "obs-pat014-pao2", + "meta": { + "versionId": "1", + "lastUpdated": "2024-05-13T18:47:33.418+00:00", + "source": "#7Gi2I1J4W4nhEeV3" + }, + "status": "final", + "category": [ + { + "coding": [ + { + "system": "http://terminology.hl7.org/CodeSystem/observation-category", + "code": "laboratory", + "display": "Laboratory" + } + ], + "text": "Laboratory" + } + ], + "code": { + "coding": [ + { + "system": "http://loinc.org", + "code": "2703-7", + "display": "Oxygen (BldA) [Partial pressure]" + } + ] + }, + "subject": { + "reference": "Patient/pat014" + }, + "issued": "2020-01-20T15:30:10+01:00", + "valueQuantity": { + "value": 65, + "unit": "mm[Hg]", + "system": "http://unitsofmeasure.org", + "code": "mm[Hg]" + }, + "interpretation": [ + { + "coding": [ + { + "system": "http://terminology.hl7.org/CodeSystem/v2-0078", + "code": "L", + "display": "LOW" + } + ], + "text": "Normal (applies to non-numeric results)" + } + ], + "referenceRange": [ + { + "low": { + "value": 75, + "unit": "mm[Hg]", + "system": "http://unitsofmeasure.org", + "code": "mm[Hg]" + }, + "high": { + "value": 100, + "unit": "mm[Hg]", + "system": "http://unitsofmeasure.org", + "code": "mm[Hg]" + } + } + ] + }, + "request": { + "method": "PUT", + "url": "Observation/obs-pat014-pao2" + } + }, + { + "fullUrl": "https://inferno-qa.healthit.gov/reference-server/r4/Practitioner/pra1234", + "resource": { + "resourceType": "Practitioner", + "id": "pra1234", + "meta": { + "versionId": "2", + "lastUpdated": "2024-05-13T18:49:28.023+00:00", + "source": "#Vv2gaGy3gC8yzXSp", + "profile": [ + "http://hl7.org/fhir/us/core/StructureDefinition/us-core-practitioner" + ] + }, + "identifier": [ + { + "system": "http://hl7.org/fhir/sid/us-npi", + "value": "1122334455" + } + ], + "name": [ + { + "use": "official", + "family": "Doe", + "given": [ + "Jane", + "Betty" + ], + "prefix": [ + "Dr." + ] + } + ], + "telecom": [ + { + "system": "phone", + "value": "716-873-1557" + }, + { + "system": "email", + "value": "jane.betty@myhospital.com" + } + ], + "address": [ + { + "use": "home", + "type": "both", + "line": [ + "840 Seneca St" + ], + "city": "Buffalo", + "state": "NY", + "postalCode": "14210" + } + ] + }, + "request": { + "method": "PUT", + "url": "Practitioner/pra1234" + } + }, + { + "fullUrl": "https://inferno-qa.healthit.gov/reference-server/r4/Practitioner/pra-sstrange", + "resource": { + "resourceType": "Practitioner", + "id": "pra-sstrange", + "meta": { + "versionId": "2", + "lastUpdated": "2024-05-13T18:49:28.347+00:00", + "source": "#AYtxGKSIbCiHd4sk" + }, + "identifier": [ + { + "system": "http://hl7.org/fhir/sid/us-npi", + "value": "1122334466" + } + ], + "name": [ + { + "use": "official", + "family": "Strange", + "given": [ + "Stephen" + ], + "prefix": [ + "Dr." + ] + } + ] + }, + "request": { + "method": "PUT", + "url": "Practitioner/pra-sstrange" + } + }, + { + "fullUrl": "https://inferno-qa.healthit.gov/reference-server/r4/Organization/org1234", + "resource": { + "resourceType": "Organization", + "id": "org1234", + "meta": { + "versionId": "2", + "lastUpdated": "2024-05-13T18:49:28.107+00:00", + "source": "#3vnjaNXQuIZbazzp" + }, + "name": "Centers for Medicare and Medicaid Services" + }, + "request": { + "method": "PUT", + "url": "Organization/org1234" + } + }, + { + "fullUrl": "https://inferno-qa.healthit.gov/reference-server/r4/DeviceRequest/devreq014", + "resource": { + "resourceType": "DeviceRequest", + "id": "devreq014", + "meta": { + "versionId": "1", + "lastUpdated": "2024-05-13T18:47:33.492+00:00", + "source": "#OVGbW6RkBrAS2SfR", + "profile": [ + "http://hl7.org/fhir/us/davinci-crd/R4/StructureDefinition/profile-devicerequest-r4" + ] + }, + "identifier": [ + { + "type": { + "coding": [ + { + "system": "http://terminology.hl7.org/CodeSystem/v2-0203", + "code": "PLAC" + } + ] + }, + "value": "600dca50-ddae-482e-b8ac-383e1ab10c18" + } + ], + "status": "draft", + "intent": "original-order", + "codeCodeableConcept": { + "coding": [ + { + "system": "https://bluebutton.cms.gov/resources/codesystem/hcpcs", + "code": "E0424", + "display": "Stationary Compressed Gaseous Oxygen System, Rental" + } + ] + }, + "subject": { + "reference": "Patient/pat014" + }, + "authoredOn": "2023-01-01T00:00:00Z", + "requester": { + "reference": "Practitioner/pra-hfairchild" + }, + "performer": { + "reference": "Practitioner/pra1255" + }, + "insurance": [ + { + "reference": "Coverage/cov014" + } + ] + }, + "request": { + "method": "PUT", + "url": "DeviceRequest/devreq014" + } + }, + { + "fullUrl": "https://inferno-qa.healthit.gov/reference-server/r4/DeviceRequest/devreq020", + "resource": { + "resourceType": "DeviceRequest", + "id": "devreq020", + "meta": { + "versionId": "1", + "lastUpdated": "2024-05-13T18:47:33.568+00:00", + "source": "#s2l91BImIb8CIKyb", + "profile": [ + "http://hl7.org/fhir/us/davinci-crd/R4/StructureDefinition/profile-devicerequest-r4" + ] + }, + "identifier": [ + { + "type": { + "coding": [ + { + "system": "http://terminology.hl7.org/CodeSystem/v2-0203", + "code": "PLAC" + } + ] + }, + "value": "26889a9f-37cb-4130-9c69-594e4c087996" + } + ], + "status": "draft", + "intent": "original-order", + "codeCodeableConcept": { + "coding": [ + { + "system": "https://bluebutton.cms.gov/resources/codesystem/hcpcs", + "code": "E0470", + "display": "Respiratory Assist Device, Bi-Level Pressure Capacity WITHOUT Backup Rate" + } + ] + }, + "subject": { + "reference": "Patient/pat014" + }, + "authoredOn": "2023-01-01T00:00:00Z", + "requester": { + "reference": "Practitioner/pra-hfairchild" + }, + "performer": { + "reference": "Practitioner/pra1255" + }, + "insurance": [ + { + "reference": "Coverage/cov014" + } + ] + }, + "request": { + "method": "PUT", + "url": "DeviceRequest/devreq020" + } + }, + { + "fullUrl": "https://inferno-qa.healthit.gov/reference-server/r4/Encounter/enc-pat014-cpape0601", + "resource": { + "resourceType": "Encounter", + "id": "enc-pat014-cpape0601", + "meta": { + "versionId": "2", + "lastUpdated": "2024-05-13T18:48:41.073+00:00", + "source": "#39Cj04jcacyDKG8I" + }, + "status": "finished", + "class": { + "system": "http://terminology.hl7.org/CodeSystem/v3-ActCode", + "code": "HH", + "display": "home health" + }, + "type": [ + { + "coding": [ + { + "system": "http://snomed.info/sct", + "code": "185345009", + "display": "Encounter for symptom" + } + ] + } + ], + "priority": { + "coding": [ + { + "system": "http://snomed.info/sct", + "code": "103391001", + "display": "Urgent" + } + ] + }, + "subject": { + "reference": "Patient/pat014", + "display": "Roosevelt Theodore" + }, + "participant": [ + { + "individual": { + "reference": "Practitioner/pra1255" + } + } + ], + "period": { + "start": "2020-07-06T10:40:10+01:00", + "end": "2020-07-06T12:40:10+01:00" + }, + "length": { + "value": 56, + "unit": "minutes", + "system": "http://unitsofmeasure.org", + "code": "min" + }, + "reasonCode": [ + { + "coding": [ + { + "system": "http://hl7.org/fhir/sid/icd-10-cm", + "code": "G47.33", + "display": "Hypopnea, obstructive sleep apnea" + } + ] + } + ], + "diagnosis": [ + { + "condition": { + "reference": "Condition/pat014-cond-osa", + "display": "The patient has sleep apnea" + }, + "use": { + "coding": [ + { + "system": "http://terminology.hl7.org/CodeSystem/diagnosis-role", + "code": "AD", + "display": "Admission diagnosis" + } + ] + }, + "rank": 2 + }, + { + "condition": { + "reference": "Condition/pat014-cond-osa", + "display": "The patient has obstructive sleep apnea" + }, + "use": { + "coding": [ + { + "system": "http://terminology.hl7.org/CodeSystem/diagnosis-role", + "code": "CC", + "display": "Chief complaint" + } + ] + }, + "rank": 1 + } + ] + }, + "request": { + "method": "PUT", + "url": "Encounter/enc-pat014-cpape0601" + } + }, + { + "fullUrl": "https://inferno-qa.healthit.gov/reference-server/r4/DeviceRequest/devreq021", + "resource": { + "resourceType": "DeviceRequest", + "id": "devreq021", + "meta": { + "versionId": "1", + "lastUpdated": "2024-05-13T18:47:33.647+00:00", + "source": "#Mfy6hZYZ9pnEuU7K", + "profile": [ + "http://hl7.org/fhir/us/davinci-crd/R4/StructureDefinition/profile-devicerequest-r4" + ] + }, + "identifier": [ + { + "type": { + "coding": [ + { + "system": "http://terminology.hl7.org/CodeSystem/v2-0203", + "code": "PLAC" + } + ] + }, + "value": "95e7e265-980f-48b7-809f-a0d38a950edc" + } + ], + "status": "draft", + "intent": "original-order", + "codeCodeableConcept": { + "coding": [ + { + "system": "https://bluebutton.cms.gov/resources/codesystem/hcpcs", + "code": "E0601", + "display": "Continuous positive airway pressure (cpap) device" + } + ] + }, + "subject": { + "reference": "Patient/pat014" + }, + "encounter": { + "reference": "Encounter/enc-pat014-cpape0601" + }, + "authoredOn": "2023-01-01T00:00:00Z", + "requester": { + "reference": "Practitioner/pra-hfairchild" + }, + "performer": { + "reference": "Practitioner/pra1255" + }, + "insurance": [ + { + "reference": "Coverage/cov014" + } + ] + }, + "request": { + "method": "PUT", + "url": "DeviceRequest/devreq021" + } + }, + { + "fullUrl": "https://inferno-qa.healthit.gov/reference-server/r4/DeviceRequest/devreq-014-e0250", + "resource": { + "resourceType": "DeviceRequest", + "id": "devreq-014-e0250", + "meta": { + "versionId": "1", + "lastUpdated": "2024-05-13T18:47:33.719+00:00", + "source": "#ZA38zK7U4QhX6KIc", + "profile": [ + "http://hl7.org/fhir/us/davinci-crd/R4/StructureDefinition/profile-devicerequest-r4" + ] + }, + "identifier": [ + { + "type": { + "coding": [ + { + "system": "http://terminology.hl7.org/CodeSystem/v2-0203", + "code": "PLAC" + } + ] + }, + "value": "d78a846a-5406-4d9c-a96f-bb677fe6a475" + } + ], + "status": "draft", + "intent": "original-order", + "codeCodeableConcept": { + "coding": [ + { + "system": "https://bluebutton.cms.gov/resources/codesystem/hcpcs", + "code": "E0250", + "display": "Hospital bed fixed height with any type of side rails, mattress" + } + ] + }, + "subject": { + "reference": "Patient/pat014" + }, + "authoredOn": "2023-01-01T00:00:00Z", + "requester": { + "reference": "Practitioner/pra-hfairchild" + }, + "performer": { + "reference": "Practitioner/pra1255" + }, + "insurance": [ + { + "reference": "Coverage/cov014" + } + ] + }, + "request": { + "method": "PUT", + "url": "DeviceRequest/devreq-014-e0250" + } + }, + { + "fullUrl": "https://inferno-qa.healthit.gov/reference-server/r4/ServiceRequest/servreq01", + "resource": { + "resourceType": "ServiceRequest", + "id": "servreq01", + "meta": { + "versionId": "1", + "lastUpdated": "2024-05-13T18:47:33.804+00:00", + "source": "#SKKFSuk25WnLNvFC" + }, + "identifier": [ + { + "type": { + "coding": [ + { + "system": "http://terminology.hl7.org/CodeSystem/v2-0203", + "code": "PLAC" + } + ] + }, + "value": "63dae4d5-1b48-41f6-b56c-8b2fdea87067" + } + ], + "status": "draft", + "intent": "order", + "category": [ + { + "coding": [ + { + "system": "http://snomed.info/sct", + "code": "386053000", + "display": "Evaluation procedure (procedure)" + } + ], + "text": "Evaluation" + } + ], + "code": { + "coding": [ + { + "system": "https://bluebutton.cms.gov/resources/codesystem/hcpcs", + "code": "A0426", + "display": "Ambulance service, advanced life support, non-emergency transport, level 1 (als 1)" + } + ], + "text": "Ambulance service Non-Emergency Transport" + }, + "subject": { + "reference": "Patient/pat014" + }, + "occurrenceDateTime": "2016-09-27", + "authoredOn": "2016-09-20", + "requester": { + "display": "Smythe Juliette, MD" + }, + "performer": [ + { + "reference": "Practitioner/pra1255" + } + ], + "reasonCode": [ + { + "text": "Physical Therapy for Hip Fracture " + } + ], + "insurance": [ + { + "reference": "Coverage/cov014" + } + ] + }, + "request": { + "method": "PUT", + "url": "ServiceRequest/servreq01" + } + }, + { + "fullUrl": "https://inferno-qa.healthit.gov/reference-server/r4/AllergyIntolerance/allergy-pat014-cashew", + "resource": { + "resourceType": "AllergyIntolerance", + "id": "allergy-pat014-cashew", + "meta": { + "versionId": "1", + "lastUpdated": "2024-05-13T18:48:39.997+00:00", + "source": "#gU1HBE7QdfQEWP41" + }, + "clinicalStatus": { + "coding": [ + { + "system": "http://terminology.hl7.org/CodeSystem/condition-clinical", + "code": "active", + "display": "Active" + } + ] + }, + "verificationStatus": { + "coding": [ + { + "system": "http://terminology.hl7.org/CodeSystem/condition-ver-status", + "code": "confirmed", + "display": "Confirmed" + } + ] + }, + "type": "allergy", + "category": [ + "food" + ], + "criticality": "high", + "code": { + "coding": [ + { + "system": "http://snomed.info/sct", + "code": "227493005", + "display": "Cashew nuts" + } + ] + }, + "patient": { + "reference": "Patient/pat014" + }, + "onsetDateTime": "2004", + "recordedDate": "2014-10-09T14:58:00+11:00", + "lastOccurrence": "2020-04", + "note": [ + { + "text": "The criticality is high because of the observed anaphylactic reaction when challenged with cashew extract." + } + ], + "reaction": [ + { + "substance": { + "coding": [ + { + "system": "http://www.nlm.nih.gov/research/umls/rxnorm", + "code": "1160593", + "display": "cashew nut allergenic extract Injectable Product" + } + ] + }, + "manifestation": [ + { + "coding": [ + { + "system": "http://snomed.info/sct", + "code": "39579001", + "display": "Anaphylactic reaction" + } + ] + } + ], + "description": "Challenge Protocol. Severe reaction to subcutaneous cashew extract. Epinephrine administered", + "onset": "2012-06-12", + "severity": "severe", + "exposureRoute": { + "coding": [ + { + "system": "http://snomed.info/sct", + "code": "34206005", + "display": "Subcutaneous route" + } + ] + } + }, + { + "manifestation": [ + { + "coding": [ + { + "system": "http://snomed.info/sct", + "code": "64305001", + "display": "Urticaria" + } + ] + } + ], + "onset": "2004", + "severity": "moderate", + "note": [ + { + "text": "The patient reports that the onset of urticaria was within 15 minutes of eating cashews." + } + ] + } + ] + }, + "request": { + "method": "PUT", + "url": "AllergyIntolerance/allergy-pat014-cashew" + } + }, + { + "fullUrl": "https://inferno-qa.healthit.gov/reference-server/r4/AllergyIntolerance/allergy-pat014-penicillin", + "resource": { + "resourceType": "AllergyIntolerance", + "id": "allergy-pat014-penicillin", + "meta": { + "versionId": "1", + "lastUpdated": "2024-05-13T18:48:40.092+00:00", + "source": "#P7nukkXyVBbO7r8d" + }, + "clinicalStatus": { + "coding": [ + { + "system": "http://terminology.hl7.org/CodeSystem/condition-clinical", + "code": "active", + "display": "Active" + } + ] + }, + "verificationStatus": { + "coding": [ + { + "system": "http://terminology.hl7.org/CodeSystem/condition-ver-status", + "code": "confirmed", + "display": "Confirmed" + } + ] + }, + "type": "allergy", + "category": [ + "medication" + ], + "criticality": "high", + "code": { + "coding": [ + { + "system": "http://www.nlm.nih.gov/research/umls/rxnorm", + "code": "7980", + "display": "Penicillin G" + } + ] + }, + "patient": { + "reference": "Patient/pat014" + }, + "onsetDateTime": "2004", + "recordedDate": "2014-10-09T14:58:00+11:00", + "lastOccurrence": "2020-04", + "reaction": [ + { + "manifestation": [ + { + "coding": [ + { + "system": "http://snomed.info/sct", + "code": "247472004", + "display": "Hives" + } + ] + } + ] + } + ] + }, + "request": { + "method": "PUT", + "url": "AllergyIntolerance/allergy-pat014-penicillin" + } + }, + { + "fullUrl": "https://inferno-qa.healthit.gov/reference-server/r4/CareTeam/pat014-hhs-careteam", + "resource": { + "resourceType": "CareTeam", + "id": "pat014-hhs-careteam", + "meta": { + "versionId": "2", + "lastUpdated": "2024-05-13T18:48:40.271+00:00", + "source": "#MX6cYo07f3Z9RbUp" + }, + "status": "active", + "category": [ + { + "coding": [ + { + "system": "http://loinc.org", + "code": "LA28866-4", + "display": "Home & Community Based Services (HCBS)-focused care team" + } + ] + } + ], + "name": "Roosevelt Theodore's Care Team for Home Health Service", + "subject": { + "reference": "Patient/pat014", + "display": "Roosevelt Theodore" + }, + "period": { + "end": "2021-06-01" + }, + "participant": [ + { + "role": [ + { + "coding": [ + { + "system": "http://nucc.org/provider-taxonomy", + "code": "163W00000X", + "display": "Registered Nurse" + } + ] + } + ], + "member": { + "reference": "Practitioner/pra-hfairchild", + "display": "Helen Fairchild" + }, + "onBehalfOf": { + "reference": "Organization/org1234" + }, + "period": { + "end": "2021-06-01" + } + } + ], + "managingOrganization": [ + { + "reference": "Organization/org1234" + } + ] + }, + "request": { + "method": "PUT", + "url": "CareTeam/pat014-hhs-careteam" + } + }, + { + "fullUrl": "https://inferno-qa.healthit.gov/reference-server/r4/Encounter/enc-pat014-hhs", + "resource": { + "resourceType": "Encounter", + "id": "enc-pat014-hhs", + "meta": { + "versionId": "2", + "lastUpdated": "2024-05-13T18:48:41.196+00:00", + "source": "#Di7UtvEeEW3H0cf5" + }, + "status": "finished", + "class": { + "system": "http://terminology.hl7.org/CodeSystem/v3-ActCode", + "code": "HH", + "display": "home health" + }, + "type": [ + { + "coding": [ + { + "system": "http://snomed.info/sct", + "code": "185345009", + "display": "Encounter for symptom" + } + ] + } + ], + "priority": { + "coding": [ + { + "system": "http://snomed.info/sct", + "code": "103391001", + "display": "Urgent" + } + ] + }, + "subject": { + "reference": "Patient/pat014", + "display": "Roosevelt Theodore" + }, + "participant": [ + { + "individual": { + "reference": "Practitioner/pra1255" + } + } + ], + "period": { + "start": "2020-06-08T10:40:10+01:00", + "end": "2020-06-08T12:40:10+01:00" + }, + "length": { + "value": 56, + "unit": "minutes", + "system": "http://unitsofmeasure.org", + "code": "min" + }, + "reasonCode": [ + { + "coding": [ + { + "system": "http://snomed.info/sct", + "code": "230690007", + "display": "Cerebrovascular accident (disorder)" + } + ] + } + ], + "diagnosis": [ + { + "condition": { + "reference": "Condition/con-pat014-stroke", + "display": "The patient is hospitalized for stroke" + }, + "use": { + "coding": [ + { + "system": "http://terminology.hl7.org/CodeSystem/diagnosis-role", + "code": "AD", + "display": "Admission diagnosis" + } + ] + }, + "rank": 2 + }, + { + "condition": { + "reference": "Condition/con-pat014-stroke", + "display": "The patient is hospitalized for stroke" + }, + "use": { + "coding": [ + { + "system": "http://terminology.hl7.org/CodeSystem/diagnosis-role", + "code": "CC", + "display": "Chief complaint" + } + ] + }, + "rank": 1 + } + ] + }, + "request": { + "method": "PUT", + "url": "Encounter/enc-pat014-hhs" + } + }, + { + "fullUrl": "https://inferno-qa.healthit.gov/reference-server/r4/Goal/pat014-hhs-careplan-goal", + "resource": { + "resourceType": "Goal", + "id": "pat014-hhs-careplan-goal", + "meta": { + "versionId": "2", + "lastUpdated": "2024-05-13T18:48:41.287+00:00", + "source": "#tVY2TaK0zpRIFl5V" + }, + "lifecycleStatus": "planned", + "achievementStatus": { + "coding": [ + { + "system": "http://terminology.hl7.org/CodeSystem/goal-achievement", + "code": "not-achieved", + "display": "Not achieved" + } + ], + "text": "Not achieved" + }, + "description": { + "text": "Move without restriction" + }, + "subject": { + "reference": "Patient/pat014", + "display": "Roosevelt Theodore" + }, + "startDate": "2020-06-20", + "outcomeCode": [ + { + "coding": [ + { + "system": "http://snomed.info/sct", + "code": "1670004", + "display": "Cerebral hemiparesis" + } + ], + "text": "Cerebral hemiparesis" + } + ] + }, + "request": { + "method": "PUT", + "url": "Goal/pat014-hhs-careplan-goal" + } + }, + { + "fullUrl": "https://inferno-qa.healthit.gov/reference-server/r4/Condition/con-pat014-stroke", + "resource": { + "resourceType": "Condition", + "id": "con-pat014-stroke", + "meta": { + "versionId": "2", + "lastUpdated": "2024-05-13T18:48:40.919+00:00", + "source": "#xWl7KsfPjDl4ICsR" + }, + "clinicalStatus": { + "coding": [ + { + "system": "http://terminology.hl7.org/CodeSystem/condition-clinical", + "code": "active", + "display": "Active" + } + ] + }, + "verificationStatus": { + "coding": [ + { + "system": "http://terminology.hl7.org/CodeSystem/condition-ver-status", + "code": "confirmed", + "display": "Confirmed" + } + ] + }, + "category": [ + { + "coding": [ + { + "system": "http://terminology.hl7.org/CodeSystem/condition-category", + "code": "encounter-diagnosis", + "display": "Encounter Diagnosis" + } + ] + } + ], + "code": { + "coding": [ + { + "system": "http://snomed.info/sct", + "code": "1670004", + "display": "Cerebral hemiparesis (disorder)" + } + ] + }, + "subject": { + "reference": "Patient/pat014" + } + }, + "request": { + "method": "PUT", + "url": "Condition/con-pat014-stroke" + } + }, + { + "fullUrl": "https://inferno-qa.healthit.gov/reference-server/r4/CarePlan/careplan-pat014-hhs", + "resource": { + "resourceType": "CarePlan", + "id": "careplan-pat014-hhs", + "meta": { + "versionId": "1", + "lastUpdated": "2024-05-13T18:48:40.168+00:00", + "source": "#zhq5XqL9jFEcvC8a" + }, + "text": { + "status": "additional", + "div": "
\n

An after acute care plan (for a stroke patient ).

\n

The plan has activities to take medication, safety measures, schedule first antenatal,\n and (there would be lots of others of course)

\n

Note that where is a proposed 'status' element against each activity

\n
" + }, + "status": "active", + "intent": "plan", + "category": [ + { + "coding": [ + { + "system": "http://hl7.org/fhir/us/core/CodeSystem/careplan-category", + "code": "assess-plan" + } + ] + } + ], + "subject": { + "reference": "Patient/pat014", + "display": "Roosevelt Theodore" + }, + "encounter": { + "reference": "Encounter/enc-pat014-hhs" + }, + "author": { + "reference": "Practitioner/pra1234", + "display": "Dr Jane Doe" + }, + "careTeam": [ + { + "reference": "CareTeam/pat014-hhs-careteam" + } + ], + "addresses": [ + { + "reference": "Condition/con-pat014-stroke", + "display": "Stroke" + } + ], + "goal": [ + { + "reference": "Goal/pat014-hhs-careplan-goal" + } + ], + "activity": [ + { + "detail": { + "kind": "DeviceRequest", + "code": { + "coding": [ + { + "system": "https://bluebutton.cms.gov/resources/codesystem/hcpcs", + "code": "E0143", + "display": "Walker, folding, wheeled, adjustable or fixed height" + } + ] + }, + "status": "completed", + "doNotPerform": false + } + }, + { + "detail": { + "kind": "NutritionOrder", + "code": { + "coding": [ + { + "system": "http://snomed.info/sct", + "code": "284093001", + "display": "Potassium supplementation" + } + ] + }, + "status": "completed", + "doNotPerform": false, + "scheduledString": "daily", + "productReference": { + "reference": "Substance/f203", + "display": "Potassium" + }, + "dailyAmount": { + "value": 80, + "unit": "mmol", + "system": "http://snomed.info/sct", + "code": "258718000" + } + } + }, + { + "detail": { + "kind": "ServiceRequest", + "code": { + "coding": [ + { + "system": "http://snomed.info/sct", + "code": "306005", + "display": "Echography of kidney" + } + ] + }, + "status": "completed", + "doNotPerform": false + } + }, + { + "detail": { + "kind": "Task", + "code": { + "text": "Secure carpets in hallways" + }, + "reasonCode": [ + { + "coding": [ + { + "system": "https://bluebutton.cms.gov/resources/codesystem/hcpcs", + "code": "C3824712", + "display": "Patients--Safety measures" + } + ], + "text": "Safety measures" + } + ], + "status": "in-progress", + "doNotPerform": false + } + } + ] + }, + "request": { + "method": "PUT", + "url": "CarePlan/careplan-pat014-hhs" + } + }, + { + "fullUrl": "https://inferno-qa.healthit.gov/reference-server/r4/ClinicalImpression/clinicalimpression-pat014-hhs", + "resource": { + "resourceType": "ClinicalImpression", + "id": "clinicalimpression-pat014-hhs", + "meta": { + "versionId": "1", + "lastUpdated": "2024-05-13T18:48:40.510+00:00", + "source": "#UeK3PcdzE5663up1" + }, + "status": "completed", + "description": "The patient is evaluated for Home Health Service after stroke", + "subject": { + "reference": "Patient/pat014" + }, + "encounter": { + "reference": "Encounter/enc-pat014-hhs" + }, + "effectivePeriod": { + "start": "2020-06-08T20:00:00+11:00", + "end": "2020-12-08T22:33:00+11:00" + }, + "date": "2020-06-08T22:33:00+11:00", + "assessor": { + "reference": "Practitioner/pra1255" + }, + "problem": [ + { + "display": "Stroke" + } + ], + "investigation": [ + { + "code": { + "text": "Initial Examination" + }, + "item": [ + { + "display": "left side paralysis" + }, + { + "display": "decreased level of speech and hearing" + } + ] + } + ], + "summary": "Complication of stroke", + "finding": [ + { + "itemCodeableConcept": { + "coding": [ + { + "system": "http://snomed.info/sct", + "code": "1670004", + "display": "Cerebral hemiparesis (disorder)" + } + ] + } + } + ], + "prognosisCodeableConcept": [ + { + "coding": [ + { + "system": "http://snomed.info/sct", + "code": "65872000", + "display": "Fair prognosis" + } + ] + } + ] + }, + "request": { + "method": "PUT", + "url": "ClinicalImpression/clinicalimpression-pat014-hhs" + } + }, + { + "fullUrl": "https://inferno-qa.healthit.gov/reference-server/r4/Condition/condition014-livertransplant", + "resource": { + "resourceType": "Condition", + "id": "condition014-livertransplant", + "meta": { + "versionId": "1", + "lastUpdated": "2024-05-13T18:48:40.613+00:00", + "source": "#0Yq2zjMfBQXjkxfM" + }, + "clinicalStatus": { + "coding": [ + { + "system": "http://terminology.hl7.org/CodeSystem/condition-clinical", + "code": "active", + "display": "Active" + } + ] + }, + "verificationStatus": { + "coding": [ + { + "system": "http://terminology.hl7.org/CodeSystem/condition-ver-status", + "code": "confirmed", + "display": "Confirmed" + } + ] + }, + "category": [ + { + "coding": [ + { + "system": "http://terminology.hl7.org/CodeSystem/condition-category", + "code": "problem-list-item", + "display": "Problem List Item" + } + ], + "text": "Problem List Item" + } + ], + "code": { + "coding": [ + { + "system": "http://snomed.info/sct", + "code": "161671001", + "display": "History of liver recipient" + } + ] + }, + "subject": { + "reference": "Patient/pat014" + }, + "recordedDate": "2014-10-09T14:58:00+11:00" + }, + "request": { + "method": "PUT", + "url": "Condition/condition014-livertransplant" + } + }, + { + "fullUrl": "https://inferno-qa.healthit.gov/reference-server/r4/Condition/pat014-cond-osa", + "resource": { + "resourceType": "Condition", + "id": "pat014-cond-osa", + "meta": { + "versionId": "1", + "lastUpdated": "2024-05-13T18:48:40.690+00:00", + "source": "#QlPBP0xq4vEwPl4D" + }, + "clinicalStatus": { + "coding": [ + { + "system": "http://terminology.hl7.org/CodeSystem/condition-clinical", + "code": "active", + "display": "Active" + } + ] + }, + "verificationStatus": { + "coding": [ + { + "system": "http://terminology.hl7.org/CodeSystem/condition-ver-status", + "code": "confirmed", + "display": "Confirmed" + } + ] + }, + "category": [ + { + "coding": [ + { + "system": "http://terminology.hl7.org/CodeSystem/condition-category", + "code": "encounter-diagnosis", + "display": "Encounter Diagnosis" + } + ] + } + ], + "code": { + "coding": [ + { + "system": "http://hl7.org/fhir/sid/icd-10-cm", + "code": "G47.33", + "display": "Hypopnea, obstructive sleep apnea" + } + ] + }, + "subject": { + "reference": "Patient/pat014" + } + }, + "request": { + "method": "PUT", + "url": "Condition/pat014-cond-osa" + } + }, + { + "fullUrl": "https://inferno-qa.healthit.gov/reference-server/r4/Condition/con-pat014-obesity", + "resource": { + "resourceType": "Condition", + "id": "con-pat014-obesity", + "meta": { + "versionId": "1", + "lastUpdated": "2024-05-13T18:48:40.763+00:00", + "source": "#95Cv15C0A3SDbhzw" + }, + "clinicalStatus": { + "coding": [ + { + "system": "http://terminology.hl7.org/CodeSystem/condition-clinical", + "code": "active" + } + ] + }, + "verificationStatus": { + "coding": [ + { + "system": "http://terminology.hl7.org/CodeSystem/condition-ver-status", + "code": "confirmed" + } + ] + }, + "category": [ + { + "coding": [ + { + "system": "http://terminology.hl7.org/CodeSystem/condition-category", + "code": "encounter-diagnosis", + "display": "Encounter Diagnosis" + } + ] + } + ], + "code": { + "coding": [ + { + "system": "http://snomed.info/sct", + "code": "83911000119104", + "display": "Severe obesity (disorder)" + } + ] + }, + "subject": { + "reference": "Patient/pat014" + } + }, + "request": { + "method": "PUT", + "url": "Condition/con-pat014-obesity" + } + }, + { + "fullUrl": "https://inferno-qa.healthit.gov/reference-server/r4/Condition/condition-pat014-shoulder-fracture-open", + "resource": { + "resourceType": "Condition", + "id": "condition-pat014-shoulder-fracture-open", + "meta": { + "versionId": "1", + "lastUpdated": "2024-05-13T18:48:40.840+00:00", + "source": "#oaQNgHNR3DRTi0S4" + }, + "clinicalStatus": { + "coding": [ + { + "system": "http://terminology.hl7.org/CodeSystem/condition-clinical", + "code": "active" + } + ] + }, + "verificationStatus": { + "coding": [ + { + "system": "http://terminology.hl7.org/CodeSystem/condition-ver-status", + "code": "confirmed" + } + ] + }, + "category": [ + { + "coding": [ + { + "system": "http://terminology.hl7.org/CodeSystem/condition-category", + "code": "encounter-diagnosis", + "display": "Encounter Diagnosis" + } + ] + } + ], + "code": { + "coding": [ + { + "system": "http://snomed.info/sct", + "code": "275337006", + "display": "Shoulder fracture - open (disorder)" + } + ] + }, + "subject": { + "reference": "Patient/pat014" + } + }, + "request": { + "method": "PUT", + "url": "Condition/condition-pat014-shoulder-fracture-open" + } + }, + { + "fullUrl": "https://inferno-qa.healthit.gov/reference-server/r4/Consent/pat014-consent", + "resource": { + "resourceType": "Consent", + "id": "pat014-consent", + "meta": { + "versionId": "1", + "lastUpdated": "2024-05-13T18:48:41.015+00:00", + "source": "#Y8FtmVw3fFGrHSvG" + }, + "status": "active", + "scope": { + "coding": [ + { + "system": "http://terminology.hl7.org/CodeSystem/consentscope", + "code": "treatment" + } + ] + }, + "category": [ + { + "coding": [ + { + "system": "http://terminology.hl7.org/CodeSystem/consentcategorycodes", + "code": "acd", + "display": "Advance Directive" + } + ] + } + ], + "patient": { + "reference": "Patient/pat014" + }, + "dateTime": "2020-06-08T12:00:10+01:00", + "performer": [ + { + "reference": "Patient/pat014" + } + ], + "organization": [ + { + "reference": "Organization/org1234" + } + ], + "policyRule": { + "coding": [ + { + "system": "http://terminology.hl7.org/CodeSystem/consentpolicycodes", + "code": "cric", + "display": "Common Rule Informed Consent" + } + ] + }, + "provision": { + "period": { + "start": "2020-06-10", + "end": "2021-06-10" + }, + "actor": [ + { + "role": { + "coding": [ + { + "system": "http://terminology.hl7.org/CodeSystem/v3-RoleClass", + "code": "PROV", + "display": "healthcare provider" + } + ] + }, + "reference": { + "reference": "Practitioner/pra1234" + } + } + ], + "provision": [ + { + "type": "permit", + "actor": [ + { + "role": { + "coding": [ + { + "system": "http://terminology.hl7.org/CodeSystem/v3-RoleClass", + "code": "AUT" + } + ] + }, + "reference": { + "reference": "Practitioner/pra1255" + } + } + ], + "code": [ + { + "coding": [ + { + "system": "http://loinc.org", + "code": "34133-9" + } + ] + }, + { + "coding": [ + { + "system": "http://loinc.org", + "code": "18842-5" + } + ] + } + ] + } + ] + } + }, + "request": { + "method": "PUT", + "url": "Consent/pat014-consent" + } + }, + { + "fullUrl": "https://inferno-qa.healthit.gov/reference-server/r4/MedicationRequest/pat014-mr-azathioprine", + "resource": { + "resourceType": "MedicationRequest", + "id": "pat014-mr-azathioprine", + "meta": { + "versionId": "2", + "lastUpdated": "2024-05-13T18:48:41.440+00:00", + "source": "#kGtfXGWRMbaPvhPC" + }, + "identifier": [ + { + "type": { + "coding": [ + { + "system": "http://terminology.hl7.org/CodeSystem/v2-0203", + "code": "PLAC" + } + ] + }, + "value": "25839b73-fcc9-4706-8c77-a806995b8109" + } + ], + "status": "active", + "intent": "order", + "medicationCodeableConcept": { + "coding": [ + { + "system": "http://www.nlm.nih.gov/research/umls/rxnorm", + "code": "105611", + "display": "azathioprine 50 MG Oral Tablet [Imuran]" + } + ] + }, + "subject": { + "reference": "Patient/pat014", + "display": "Theodor Roosevelt" + }, + "authoredOn": "2020-05-11", + "requester": { + "reference": "Practitioner/pra1234", + "display": "Jane Doe" + }, + "reasonCode": [ + { + "coding": [ + { + "system": "http://snomed.info/sct", + "code": "52042003", + "display": "Systemic lupus erythematosus glomerulonephritis syndrome, World Health Organization class V (disorder)" + } + ] + } + ], + "insurance": [ + { + "reference": "Coverage/cov014" + } + ], + "dosageInstruction": [ + { + "sequence": 1, + "text": "50 mg PO daily for remission induction", + "timing": { + "repeat": { + "frequency": 1, + "period": 1, + "periodUnit": "d" + } + }, + "route": { + "coding": [ + { + "system": "http://snomed.info/sct", + "code": "26643006", + "display": "Oral route (qualifier value)" + } + ] + }, + "doseAndRate": [ + { + "type": { + "coding": [ + { + "system": "http://terminology.hl7.org/CodeSystem/dose-rate-type", + "code": "ordered", + "display": "Ordered" + } + ] + }, + "doseQuantity": { + "value": 50, + "unit": "mg", + "system": "http://unitsofmeasure.org", + "code": "mg" + } + } + ] + } + ], + "dispenseRequest": { + "numberOfRepeatsAllowed": 3, + "quantity": { + "value": 90, + "system": "http://terminology.hl7.org/CodeSystem/v3-orderableDrugForm", + "code": "TAB" + } + } + }, + "request": { + "method": "PUT", + "url": "MedicationRequest/pat014-mr-azathioprine" + } + }, + { + "fullUrl": "https://inferno-qa.healthit.gov/reference-server/r4/MedicationDispense/pat014-meddispsense-azathioprine", + "resource": { + "resourceType": "MedicationDispense", + "id": "pat014-meddispsense-azathioprine", + "meta": { + "versionId": "1", + "lastUpdated": "2024-05-13T18:48:41.365+00:00", + "source": "#t9fyzoyNapa7ec8g" + }, + "identifier": [ + { + "type": { + "coding": [ + { + "system": "http://terminology.hl7.org/CodeSystem/v2-0203", + "code": "PLAC" + } + ] + }, + "value": "8e1c318e-8518-4488-ab70-89a3ab8cd741" + } + ], + "status": "in-progress", + "medicationCodeableConcept": { + "coding": [ + { + "system": "http://www.nlm.nih.gov/research/umls/rxnorm", + "code": "105611", + "display": "azathioprine 50 MG Oral Tablet [Imuran]" + } + ] + }, + "subject": { + "reference": "Patient/pat014", + "display": "Theodor Roosevelt" + }, + "performer": [ + { + "actor": { + "reference": "Practitioner/pra1234" + } + } + ], + "authorizingPrescription": [ + { + "reference": "MedicationRequest/pat014-mr-azathioprine" + } + ], + "quantity": { + "value": 90, + "system": "http://terminology.hl7.org/CodeSystem/v3-orderableDrugForm", + "code": "TAB" + }, + "daysSupply": { + "value": 90, + "unit": "Day", + "system": "http://unitsofmeasure.org", + "code": "d" + }, + "whenHandedOver": "2020-11-11", + "dosageInstruction": [ + { + "sequence": 1, + "text": "50 mg PO daily for remission induction", + "timing": { + "repeat": { + "frequency": 1, + "period": 1, + "periodUnit": "d" + } + }, + "route": { + "coding": [ + { + "system": "http://snomed.info/sct", + "code": "26643006", + "display": "Oral route (qualifier value)" + } + ] + }, + "doseAndRate": [ + { + "type": { + "coding": [ + { + "system": "http://terminology.hl7.org/CodeSystem/dose-rate-type", + "code": "ordered", + "display": "Ordered" + } + ] + }, + "doseQuantity": { + "value": 50, + "unit": "mg", + "system": "http://unitsofmeasure.org", + "code": "mg" + } + } + ] + } + ] + }, + "request": { + "method": "PUT", + "url": "MedicationDispense/pat014-meddispsense-azathioprine" + } + }, + { + "fullUrl": "https://inferno-qa.healthit.gov/reference-server/r4/MedicationRequest/pat014-mr-methotrexate", + "resource": { + "resourceType": "MedicationRequest", + "id": "pat014-mr-methotrexate", + "meta": { + "versionId": "1", + "lastUpdated": "2024-05-13T18:48:41.508+00:00", + "source": "#5i44p6a79xInEo1V" + }, + "identifier": [ + { + "type": { + "coding": [ + { + "system": "http://terminology.hl7.org/CodeSystem/v2-0203", + "code": "PLAC" + } + ] + }, + "value": "9ae058cc-ffdc-4680-b39f-6a38bfde01ac" + } + ], + "status": "active", + "intent": "order", + "medicationCodeableConcept": { + "coding": [ + { + "system": "http://www.nlm.nih.gov/research/umls/rxnorm", + "code": "105585", + "display": "methotrexate 2.5 MG Oral Tablet" + } + ] + }, + "subject": { + "reference": "Patient/pat014", + "display": "Theodor Roosevelt" + }, + "authoredOn": "2020-07-11", + "requester": { + "reference": "Practitioner/pra1234", + "display": "Jane Doe" + }, + "reasonCode": [ + { + "coding": [ + { + "system": "http://snomed.info/sct", + "code": "52042003", + "display": "Systemic lupus erythematosus glomerulonephritis syndrome, World Health Organization class V (disorder)" + } + ] + } + ], + "insurance": [ + { + "reference": "Coverage/cov014" + } + ], + "dosageInstruction": [ + { + "sequence": 1, + "text": "7.5 mg PO daily for remission induction", + "timing": { + "repeat": { + "frequency": 1, + "period": 1, + "periodUnit": "d" + } + }, + "route": { + "coding": [ + { + "system": "http://snomed.info/sct", + "code": "26643006", + "display": "Oral route (qualifier value)" + } + ] + }, + "doseAndRate": [ + { + "type": { + "coding": [ + { + "system": "http://terminology.hl7.org/CodeSystem/dose-rate-type", + "code": "ordered", + "display": "Ordered" + } + ] + }, + "doseQuantity": { + "value": 7.5, + "unit": "mg", + "system": "http://unitsofmeasure.org", + "code": "mg" + } + } + ] + } + ], + "dispenseRequest": { + "numberOfRepeatsAllowed": 3, + "quantity": { + "value": 90, + "system": "http://terminology.hl7.org/CodeSystem/v3-orderableDrugForm", + "code": "TAB" + } + } + }, + "request": { + "method": "PUT", + "url": "MedicationRequest/pat014-mr-methotrexate" + } + }, + { + "fullUrl": "https://inferno-qa.healthit.gov/reference-server/r4/MedicationStatement/medstate-pat014-azathio", + "resource": { + "resourceType": "MedicationStatement", + "id": "medstate-pat014-azathio", + "meta": { + "versionId": "1", + "lastUpdated": "2024-05-13T18:48:41.696+00:00", + "source": "#6NsbHDn7Gh79G6V7" + }, + "contained": [ + { + "resourceType": "Medication", + "id": "med-azathioprine", + "code": { + "coding": [ + { + "system": "http://hl7.org/fhir/sid/ndc", + "code": "68462-502-01", + "display": "Azathioprine" + } + ] + }, + "form": { + "coding": [ + { + "system": "http://snomed.info/sct", + "code": "385057009", + "display": "Film-coated tablet (qualifier value)" + } + ] + }, + "ingredient": [ + { + "itemCodeableConcept": { + "coding": [ + { + "system": "http://snomed.info/sct", + "code": "778409008", + "display": "Product containing only azathioprine in oral dose form" + } + ] + }, + "strength": { + "numerator": { + "value": 50, + "system": "http://unitsofmeasure.org", + "code": "mg" + }, + "denominator": { + "value": 1, + "system": "http://terminology.hl7.org/CodeSystem/v3-orderableDrugForm", + "code": "Tab" + } + } + } + ], + "batch": { + "lotNumber": "9494788", + "expirationDate": "2025-05-22" + } + } + ], + "status": "active", + "category": { + "coding": [ + { + "system": "http://terminology.hl7.org/CodeSystem/medication-statement-category", + "code": "inpatient", + "display": "Inpatient" + } + ] + }, + "medicationCodeableConcept": { + "coding": [ + { + "system": "http://www.nlm.nih.gov/research/umls/rxnorm", + "code": "105611", + "display": "azathioprine 50 MG Oral Tablet [Imuran]" + } + ] + }, + "subject": { + "reference": "Patient/pat014", + "display": "Roosevelt Theodore" + }, + "effectiveDateTime": "2020-05-10", + "dateAsserted": "2020-05-22", + "informationSource": { + "reference": "Patient/pat014", + "display": "Roosevelt Theodore" + }, + "derivedFrom": [ + { + "reference": "MedicationRequest/pat014-mr-azathioprine" + } + ], + "reasonCode": [ + { + "coding": [ + { + "system": "http://snomed.info/sct", + "code": "52042003", + "display": "Systemic lupus erythematosus glomerulonephritis syndrome" + } + ] + } + ], + "dosage": [ + { + "sequence": 1, + "text": "1-2 tablets once daily at bedtime as needed for restless legs", + "additionalInstruction": [ + { + "text": "Taking at bedtime" + } + ], + "timing": { + "repeat": { + "frequency": 1, + "period": 1, + "periodUnit": "d" + } + }, + "asNeededCodeableConcept": { + "coding": [ + { + "system": "http://snomed.info/sct", + "code": "32914008", + "display": "Restless Legs" + } + ] + }, + "route": { + "coding": [ + { + "system": "http://snomed.info/sct", + "code": "26643006", + "display": "Oral Route" + } + ] + }, + "doseAndRate": [ + { + "type": { + "coding": [ + { + "system": "http://terminology.hl7.org/CodeSystem/dose-rate-type", + "code": "ordered", + "display": "Ordered" + } + ] + }, + "doseRange": { + "low": { + "value": 1, + "unit": "TAB", + "system": "http://terminology.hl7.org/CodeSystem/v3-orderableDrugForm", + "code": "TAB" + }, + "high": { + "value": 2, + "unit": "TAB", + "system": "http://terminology.hl7.org/CodeSystem/v3-orderableDrugForm", + "code": "TAB" + } + } + } + ] + } + ] + }, + "request": { + "method": "PUT", + "url": "MedicationStatement/medstate-pat014-azathio" + } + }, + { + "fullUrl": "https://inferno-qa.healthit.gov/reference-server/r4/MedicationStatement/medstate-pat014-metho", + "resource": { + "resourceType": "MedicationStatement", + "id": "medstate-pat014-metho", + "meta": { + "versionId": "1", + "lastUpdated": "2024-05-13T18:48:41.794+00:00", + "source": "#HQsvrl8tBxdRSbf9" + }, + "contained": [ + { + "resourceType": "Medication", + "id": "med-methotrexate", + "code": { + "coding": [ + { + "system": "http://hl7.org/fhir/sid/ndc", + "code": "00555-0928-01", + "display": "Methotrexate" + } + ] + }, + "form": { + "coding": [ + { + "system": "http://snomed.info/sct", + "code": "385057009", + "display": "Film-coated tablet (qualifier value)" + } + ] + }, + "ingredient": [ + { + "itemCodeableConcept": { + "coding": [ + { + "system": "http://snomed.info/sct", + "code": "776732000", + "display": "Product containing only methotrexate (medicinal product)" + } + ] + }, + "strength": { + "numerator": { + "value": 7.5, + "system": "http://unitsofmeasure.org", + "code": "mg" + }, + "denominator": { + "value": 1, + "system": "http://terminology.hl7.org/CodeSystem/v3-orderableDrugForm", + "code": "TAB" + } + } + } + ], + "batch": { + "lotNumber": "12345", + "expirationDate": "2026-06-23" + } + } + ], + "status": "active", + "category": { + "coding": [ + { + "system": "http://terminology.hl7.org/CodeSystem/medication-statement-category", + "code": "inpatient", + "display": "Inpatient" + } + ] + }, + "medicationCodeableConcept": { + "coding": [ + { + "system": "http://hl7.org/fhir/sid/ndc", + "code": "00555-0928-01", + "display": "Methotrexate" + } + ] + }, + "subject": { + "reference": "Patient/pat014", + "display": "Roosevelt Theodore" + }, + "effectiveDateTime": "2020-07-10", + "dateAsserted": "2020-07-22", + "informationSource": { + "reference": "Patient/pat014", + "display": "Roosevelt Theodore" + }, + "derivedFrom": [ + { + "reference": "MedicationRequest/pat014-mr-methotrexate" + } + ], + "reasonCode": [ + { + "coding": [ + { + "system": "http://snomed.info/sct", + "code": "52042003", + "display": "Systemic lupus erythematosus glomerulonephritis syndrome" + } + ] + } + ], + "dosage": [ + { + "sequence": 1, + "text": "1-2 tablets once daily at bedtime as needed for restless legs", + "additionalInstruction": [ + { + "text": "Taking at bedtime" + } + ], + "timing": { + "repeat": { + "frequency": 1, + "period": 1, + "periodUnit": "d" + } + }, + "asNeededCodeableConcept": { + "coding": [ + { + "system": "http://snomed.info/sct", + "code": "32914008", + "display": "Restless Legs" + } + ] + }, + "route": { + "coding": [ + { + "system": "http://snomed.info/sct", + "code": "26643006", + "display": "Oral Route" + } + ] + }, + "doseAndRate": [ + { + "type": { + "coding": [ + { + "system": "http://terminology.hl7.org/CodeSystem/dose-rate-type", + "code": "ordered", + "display": "Ordered" + } + ] + }, + "doseRange": { + "low": { + "value": 1, + "unit": "TAB", + "system": "http://terminology.hl7.org/CodeSystem/v3-orderableDrugForm", + "code": "TAB" + }, + "high": { + "value": 2, + "unit": "TAB", + "system": "http://terminology.hl7.org/CodeSystem/v3-orderableDrugForm", + "code": "TAB" + } + } + } + ] + } + ] + }, + "request": { + "method": "PUT", + "url": "MedicationStatement/medstate-pat014-metho" + } + }, + { + "fullUrl": "https://inferno-qa.healthit.gov/reference-server/r4/MedicationStatement/medstate-pat014-tylenol", + "resource": { + "resourceType": "MedicationStatement", + "id": "medstate-pat014-tylenol", + "meta": { + "versionId": "1", + "lastUpdated": "2024-05-13T18:48:41.870+00:00", + "source": "#vj2p7iCA3FQVzM30" + }, + "contained": [ + { + "resourceType": "Medication", + "id": "med-azathioprine", + "code": { + "coding": [ + { + "system": "http://hl7.org/fhir/sid/ndc", + "code": "50580-506-02", + "display": "Tylenol PM" + } + ] + }, + "form": { + "coding": [ + { + "system": "http://snomed.info/sct", + "code": "385057009", + "display": "Film-coated tablet (qualifier value)" + } + ] + }, + "batch": { + "lotNumber": "9494788", + "expirationDate": "2025-05-22" + } + } + ], + "status": "active", + "category": { + "coding": [ + { + "system": "http://terminology.hl7.org/CodeSystem/medication-statement-category", + "code": "inpatient", + "display": "Inpatient" + } + ] + }, + "medicationCodeableConcept": { + "coding": [ + { + "system": "http://hl7.org/fhir/sid/ndc", + "code": "50580-506-02", + "display": "Tylenol PM" + } + ] + }, + "subject": { + "reference": "Patient/pat014", + "display": "Roosevelt Theodore" + }, + "effectiveDateTime": "2020-09-10", + "dateAsserted": "2020-09-22", + "informationSource": { + "reference": "Patient/pat014", + "display": "Roosevelt Theodore" + }, + "derivedFrom": [ + { + "reference": "MedicationRequest/pat014-mr-azathioprine" + } + ], + "reasonCode": [ + { + "coding": [ + { + "system": "http://snomed.info/sct", + "code": "52042003", + "display": "Systemic lupus erythematosus glomerulonephritis syndrome" + } + ] + } + ], + "dosage": [ + { + "sequence": 1, + "text": "1-2 tablets once daily at bedtime as needed for restless legs", + "additionalInstruction": [ + { + "text": "Taking at bedtime" + } + ], + "timing": { + "repeat": { + "frequency": 1, + "period": 1, + "periodUnit": "d" + } + }, + "asNeededCodeableConcept": { + "coding": [ + { + "system": "http://snomed.info/sct", + "code": "32914008", + "display": "Restless Legs" + } + ] + }, + "route": { + "coding": [ + { + "system": "http://snomed.info/sct", + "code": "26643006", + "display": "Oral Route" + } + ] + }, + "doseAndRate": [ + { + "type": { + "coding": [ + { + "system": "http://terminology.hl7.org/CodeSystem/dose-rate-type", + "code": "ordered", + "display": "Ordered" + } + ] + }, + "doseRange": { + "low": { + "value": 1, + "unit": "TAB", + "system": "http://terminology.hl7.org/CodeSystem/v3-orderableDrugForm", + "code": "TAB" + }, + "high": { + "value": 2, + "unit": "TAB", + "system": "http://terminology.hl7.org/CodeSystem/v3-orderableDrugForm", + "code": "TAB" + } + } + } + ] + } + ] + }, + "request": { + "method": "PUT", + "url": "MedicationStatement/medstate-pat014-tylenol" + } + }, + { + "fullUrl": "https://inferno-qa.healthit.gov/reference-server/r4/Observation/obs014-ahi", + "resource": { + "resourceType": "Observation", + "id": "obs014-ahi", + "meta": { + "versionId": "1", + "lastUpdated": "2024-05-13T18:49:26.940+00:00", + "source": "#5OHLnEbidt4Qyzhw" + }, + "status": "final", + "category": [ + { + "coding": [ + { + "system": "http://terminology.hl7.org/CodeSystem/observation-category", + "code": "laboratory", + "display": "Laboratory" + } + ], + "text": "Laboratory" + } + ], + "code": { + "coding": [ + { + "system": "http://loinc.org", + "code": "69990-0", + "display": "Apnea hypopnea index 24 hour" + } + ] + }, + "subject": { + "reference": "Patient/pat014" + }, + "issued": "2020-07-05T15:30:10+01:00", + "valueQuantity": { + "value": 25, + "unit": "/h", + "system": "http://unitsofmeasure.org", + "code": "/h" + }, + "interpretation": [ + { + "coding": [ + { + "system": "http://terminology.hl7.org/CodeSystem/v2-0078", + "code": "H", + "display": "HIGH" + } + ], + "text": "High (applies to non-numeric results)" + } + ] + }, + "request": { + "method": "PUT", + "url": "Observation/obs014-ahi" + } + }, + { + "fullUrl": "https://inferno-qa.healthit.gov/reference-server/r4/Observation/obs014-fev1-fvc-ratio", + "resource": { + "resourceType": "Observation", + "id": "obs014-fev1-fvc-ratio", + "meta": { + "versionId": "1", + "lastUpdated": "2024-05-13T18:49:27.048+00:00", + "source": "#lQY8QqcPGaMFVltV" + }, + "status": "final", + "category": [ + { + "coding": [ + { + "system": "http://terminology.hl7.org/CodeSystem/observation-category", + "code": "laboratory", + "display": "Laboratory" + } + ], + "text": "Laboratory" + } + ], + "code": { + "coding": [ + { + "system": "http://loinc.org", + "code": "19926-5", + "display": "FEV1/FVC" + } + ] + }, + "subject": { + "reference": "Patient/pat014" + }, + "issued": "2020-05-05T15:30:10+01:00", + "valueQuantity": { + "value": 0.5, + "unit": "%", + "system": "http://unitsofmeasure.org", + "code": "%" + }, + "interpretation": [ + { + "coding": [ + { + "system": "http://terminology.hl7.org/CodeSystem/v2-0078", + "code": "L", + "display": "LOW" + } + ], + "text": "Low (applies to non-numeric results)" + } + ] + }, + "request": { + "method": "PUT", + "url": "Observation/obs014-fev1-fvc-ratio" + } + }, + { + "fullUrl": "https://inferno-qa.healthit.gov/reference-server/r4/Observation/obs014-fvc", + "resource": { + "resourceType": "Observation", + "id": "obs014-fvc", + "meta": { + "versionId": "1", + "lastUpdated": "2024-05-13T18:49:27.158+00:00", + "source": "#GPQDX5zLnWd2ebN5" + }, + "status": "final", + "category": [ + { + "coding": [ + { + "system": "http://terminology.hl7.org/CodeSystem/observation-category", + "code": "laboratory", + "display": "Laboratory" + } + ], + "text": "Laboratory" + } + ], + "code": { + "coding": [ + { + "system": "http://loinc.org", + "code": "20149-1", + "display": "FEV1 Predicted" + } + ] + }, + "subject": { + "reference": "Patient/pat014" + }, + "issued": "2020-05-05T15:30:10+01:00", + "valueQuantity": { + "value": 0.5, + "unit": "%", + "system": "http://unitsofmeasure.org", + "code": "%" + }, + "interpretation": [ + { + "coding": [ + { + "system": "http://terminology.hl7.org/CodeSystem/v2-0078", + "code": "L", + "display": "LOW" + } + ], + "text": "Low (applies to non-numeric results)" + } + ] + }, + "request": { + "method": "PUT", + "url": "Observation/obs014-fvc" + } + }, + { + "fullUrl": "https://inferno-qa.healthit.gov/reference-server/r4/Observation/obs014-hemocrit-test", + "resource": { + "resourceType": "Observation", + "id": "obs014-hemocrit-test", + "meta": { + "versionId": "1", + "lastUpdated": "2024-05-13T18:49:27.240+00:00", + "source": "#4Rw3hTRPHW0Wc7lv" + }, + "status": "final", + "category": [ + { + "coding": [ + { + "system": "http://terminology.hl7.org/CodeSystem/observation-category", + "code": "laboratory", + "display": "laboratory" + } + ], + "text": "Laboratory" + } + ], + "code": { + "coding": [ + { + "system": "http://loinc.org", + "code": "31100-1", + "display": "Hematocrit [Volume Fraction] of Blood by Impedance" + } + ] + }, + "subject": { + "reference": "Patient/pat014" + }, + "issued": "2020-04-05T15:30:10+01:00", + "valueQuantity": { + "value": 72, + "unit": "%", + "system": "http://unitsofmeasure.org", + "code": "%" + } + }, + "request": { + "method": "PUT", + "url": "Observation/obs014-hemocrit-test" + } + }, + { + "fullUrl": "https://inferno-qa.healthit.gov/reference-server/r4/Observation/obs014-mip", + "resource": { + "resourceType": "Observation", + "id": "obs014-mip", + "meta": { + "versionId": "1", + "lastUpdated": "2024-05-13T18:49:27.311+00:00", + "source": "#1EPm1dbNtgNwfivm" + }, + "status": "final", + "category": [ + { + "coding": [ + { + "system": "http://terminology.hl7.org/CodeSystem/observation-category", + "code": "laboratory", + "display": "Laboratory" + } + ], + "text": "Laboratory" + } + ], + "code": { + "coding": [ + { + "system": "http://loinc.org", + "code": "19976-0", + "display": "Maximum [Pressure] Respiratory system airway opening --during inspiration on ventilator" + } + ] + }, + "subject": { + "reference": "Patient/pat014" + }, + "issued": "2020-05-05T15:30:10+01:00", + "valueQuantity": { + "value": 10, + "unit": "cm[H2O]", + "system": "http://unitsofmeasure.org", + "code": "cm[H2O]" + }, + "interpretation": [ + { + "coding": [ + { + "system": "http://terminology.hl7.org/CodeSystem/v2-0078", + "code": "L", + "display": "LOW" + } + ], + "text": "Low (applies to non-numeric results)" + } + ] + }, + "request": { + "method": "PUT", + "url": "Observation/obs014-mip" + } + }, + { + "fullUrl": "https://inferno-qa.healthit.gov/reference-server/r4/Observation/obs014-o2sat", + "resource": { + "resourceType": "Observation", + "id": "obs014-o2sat", + "meta": { + "versionId": "1", + "lastUpdated": "2024-05-13T18:49:27.364+00:00", + "source": "#aIEY54f4V1obdT5T" + }, + "status": "final", + "category": [ + { + "coding": [ + { + "system": "http://terminology.hl7.org/CodeSystem/observation-category", + "code": "vital-signs", + "display": "Vital Signs" + } + ], + "text": "Vital Signs" + } + ], + "code": { + "coding": [ + { + "system": "http://loinc.org", + "code": "59408-5", + "display": "Oxygen saturation in Arterial blood by Pulse oximetry" + } + ] + }, + "subject": { + "reference": "Patient/pat014" + }, + "issued": "2020-04-05T15:30:10+01:00", + "valueQuantity": { + "value": 91, + "unit": "%", + "system": "http://unitsofmeasure.org", + "code": "%" + }, + "interpretation": [ + { + "coding": [ + { + "system": "http://terminology.hl7.org/CodeSystem/v2-0078", + "code": "N", + "display": "Normal" + } + ], + "text": "Normal (applies to non-numeric results)" + } + ], + "referenceRange": [ + { + "low": { + "value": 90, + "unit": "%", + "system": "http://unitsofmeasure.org", + "code": "%" + }, + "high": { + "value": 99, + "unit": "%", + "system": "http://unitsofmeasure.org", + "code": "%" + } + } + ] + }, + "request": { + "method": "PUT", + "url": "Observation/obs014-o2sat" + } + }, + { + "fullUrl": "https://inferno-qa.healthit.gov/reference-server/r4/Observation/obs-pat014-o2excercise", + "resource": { + "resourceType": "Observation", + "id": "obs-pat014-o2excercise", + "meta": { + "versionId": "1", + "lastUpdated": "2024-05-13T18:49:27.427+00:00", + "source": "#IisgJV7XkS690oLa" + }, + "status": "final", + "category": [ + { + "coding": [ + { + "system": "http://terminology.hl7.org/CodeSystem/observation-category", + "code": "vital-signs", + "display": "Vital Signs" + } + ], + "text": "Vital Signs" + } + ], + "code": { + "coding": [ + { + "system": "http://loinc.org", + "code": "89276-0", + "display": "Oxygen saturation with exercise" + } + ] + }, + "subject": { + "reference": "Patient/pat014" + }, + "issued": "2020-04-06T15:30:10+01:00", + "valueQuantity": { + "value": 80, + "unit": "%", + "system": "http://unitsofmeasure.org", + "code": "%" + }, + "interpretation": [ + { + "coding": [ + { + "system": "http://terminology.hl7.org/CodeSystem/v2-0078", + "code": "L", + "display": "LOW" + } + ], + "text": "Low (applies to non-numeric results)" + } + ], + "referenceRange": [ + { + "low": { + "value": 90, + "unit": "%", + "system": "http://unitsofmeasure.org", + "code": "%" + }, + "high": { + "value": 99, + "unit": "%", + "system": "http://unitsofmeasure.org", + "code": "%" + } + } + ] + }, + "request": { + "method": "PUT", + "url": "Observation/obs-pat014-o2excercise" + } + }, + { + "fullUrl": "https://inferno-qa.healthit.gov/reference-server/r4/Observation/obs014-pao2", + "resource": { + "resourceType": "Observation", + "id": "obs014-pao2", + "meta": { + "versionId": "1", + "lastUpdated": "2024-05-13T18:49:27.500+00:00", + "source": "#vRiPnHo8rMEbhYbw" + }, + "status": "final", + "category": [ + { + "coding": [ + { + "system": "http://terminology.hl7.org/CodeSystem/observation-category", + "code": "vital-signs", + "display": "Vital Signs" + } + ], + "text": "Vital Signs" + } + ], + "code": { + "coding": [ + { + "system": "http://loinc.org", + "code": "2703-7", + "display": "Oxygen (BldA) [Partial pressure]" + } + ] + }, + "subject": { + "reference": "Patient/pat014" + }, + "issued": "2020-04-05T15:30:10+01:00", + "valueQuantity": { + "value": 65, + "unit": "mm[Hg]", + "system": "http://unitsofmeasure.org", + "code": "mm[Hg]" + }, + "interpretation": [ + { + "coding": [ + { + "system": "http://terminology.hl7.org/CodeSystem/v2-0078", + "code": "L", + "display": "LOW" + } + ], + "text": "Low (applies to non-numeric results)" + } + ], + "referenceRange": [ + { + "low": { + "value": 75, + "unit": "mm[Hg]", + "system": "http://unitsofmeasure.org", + "code": "mm[Hg]" + }, + "high": { + "value": 100, + "unit": "mm[Hg]", + "system": "http://unitsofmeasure.org", + "code": "mm[Hg]" + } + } + ] + }, + "request": { + "method": "PUT", + "url": "Observation/obs014-pao2" + } + }, + { + "fullUrl": "https://inferno-qa.healthit.gov/reference-server/r4/Observation/obs014-pap", + "resource": { + "resourceType": "Observation", + "id": "obs014-pap", + "meta": { + "versionId": "1", + "lastUpdated": "2024-05-13T18:49:27.564+00:00", + "source": "#n4RZ298wkXdYjfuf" + }, + "status": "final", + "category": [ + { + "coding": [ + { + "system": "http://terminology.hl7.org/CodeSystem/observation-category", + "code": "laboratory", + "display": "Laboratory" + } + ], + "text": "Laboratory" + } + ], + "code": { + "coding": [ + { + "system": "http://loinc.org", + "code": "8414-5", + "display": "Pulmonary artery Mean blood pressure" + } + ] + }, + "subject": { + "reference": "Patient/pat014" + }, + "issued": "2020-04-05T15:30:10+01:00", + "valueQuantity": { + "value": 25, + "unit": "mm[Hg]", + "system": "http://unitsofmeasure.org", + "code": "mm[Hg]" + }, + "interpretation": [ + { + "coding": [ + { + "system": "http://terminology.hl7.org/CodeSystem/v2-0078", + "code": "H", + "display": "HIGH" + } + ], + "text": "High (applies to non-numeric results)" + } + ], + "referenceRange": [ + { + "low": { + "value": 8, + "unit": "mm[Hg]", + "system": "http://unitsofmeasure.org", + "code": "mm[Hg]" + }, + "high": { + "value": 20, + "unit": "mm[Hg]", + "system": "http://unitsofmeasure.org", + "code": "mm[Hg]" + } + } + ] + }, + "request": { + "method": "PUT", + "url": "Observation/obs014-pap" + } + }, + { + "fullUrl": "https://inferno-qa.healthit.gov/reference-server/r4/Observation/obs014-rdi", + "resource": { + "resourceType": "Observation", + "id": "obs014-rdi", + "meta": { + "versionId": "1", + "lastUpdated": "2024-05-13T18:49:27.642+00:00", + "source": "#uxymbfPEgMr2hchF" + }, + "status": "final", + "category": [ + { + "coding": [ + { + "system": "http://terminology.hl7.org/CodeSystem/observation-category", + "code": "laboratory", + "display": "Laboratory" + } + ], + "text": "Laboratory" + } + ], + "code": { + "coding": [ + { + "system": "http://loinc.org", + "code": "90566-1", + "display": "Respiratory disturbance index" + } + ] + }, + "subject": { + "reference": "Patient/pat014" + }, + "issued": "2020-07-05T15:30:10+01:00", + "valueQuantity": { + "value": 15, + "unit": "/h", + "system": "http://unitsofmeasure.org", + "code": "/h" + }, + "interpretation": [ + { + "coding": [ + { + "system": "http://terminology.hl7.org/CodeSystem/v2-0078", + "code": "H", + "display": "HIGH" + } + ], + "text": "High (applies to non-numeric results)" + } + ] + }, + "request": { + "method": "PUT", + "url": "Observation/obs014-rdi" + } + }, + { + "fullUrl": "https://inferno-qa.healthit.gov/reference-server/r4/Procedure/proced014-kidney-transplant", + "resource": { + "resourceType": "Procedure", + "id": "proced014-kidney-transplant", + "meta": { + "versionId": "1", + "lastUpdated": "2024-05-13T18:49:27.742+00:00", + "source": "#kP3mBW1ou2ndZT7w" + }, + "status": "completed", + "code": { + "coding": [ + { + "system": "http://snomed.info/sct", + "code": "70536003", + "display": "Transplant of kidney" + } + ] + }, + "subject": { + "reference": "Patient/pat014" + }, + "performedPeriod": { + "start": "2010-01-28T13:31:00+01:00", + "end": "2010-01-28T20:27:00+01:00" + }, + "performer": [ + { + "actor": { + "reference": "Practitioner/pra1255", + "display": "Dr Smythe Juliette" + } + } + ] + }, + "request": { + "method": "PUT", + "url": "Procedure/proced014-kidney-transplant" + } + }, + { + "fullUrl": "https://inferno-qa.healthit.gov/reference-server/r4/ServiceRequest/servreq-pat014-hhs", + "resource": { + "resourceType": "ServiceRequest", + "id": "servreq-pat014-hhs", + "meta": { + "versionId": "1", + "lastUpdated": "2024-05-13T18:49:27.811+00:00", + "source": "#bsuMY04fXFTp1phF" + }, + "identifier": [ + { + "type": { + "coding": [ + { + "system": "http://terminology.hl7.org/CodeSystem/v2-0203", + "code": "PLAC" + } + ] + }, + "value": "f8b50a61-d92e-4fe9-b835-64d52ba6bcfc" + } + ], + "basedOn": [ + { + "reference": "CarePlan/careplan-pat014-hhs" + } + ], + "status": "draft", + "intent": "order", + "code": { + "coding": [ + { + "system": "https://bluebutton.cms.gov/resources/codesystem/hcpcs", + "code": "G0180", + "display": "Medicare-covered home health services under a home health plan of care" + } + ] + }, + "subject": { + "reference": "Patient/pat014" + }, + "encounter": { + "reference": "Encounter/enc-pat014-hhs" + }, + "occurrenceDateTime": "2020-06-08", + "authoredOn": "2020-06-08", + "requester": { + "display": "Smythe Juliette, MD" + }, + "performer": [ + { + "reference": "Practitioner/pra1255" + } + ], + "insurance": [ + { + "reference": "Coverage/cov014" + } + ], + "relevantHistory": [ + { + "reference": "Provenance/provenance-pat014-hhs" + } + ] + }, + "request": { + "method": "PUT", + "url": "ServiceRequest/servreq-pat014-hhs" + } + }, + { + "fullUrl": "https://inferno-qa.healthit.gov/reference-server/r4/Appointment/125", + "resource": { + "resourceType": "Appointment", + "id": "125", + "meta": { + "versionId": "1", + "lastUpdated": "2024-05-13T18:49:27.874+00:00", + "source": "#S9JdYcdm3S80fj13" + }, + "status": "proposed", + "serviceType": [ + { + "coding": [ + { + "system": "http://terminology.hl7.org/CodeSystem/service-type", + "code": "183", + "display": "Sleep Medicine" + } + ] + } + ], + "appointmentType": { + "coding": [ + { + "system": "http://hl7.org/fhir/v2/0276", + "code": "FOLLOWUP", + "display": "A follow up visit from a previous appointment" + } + ] + }, + "description": "CPAP adjustments", + "start": "2019-08-10T09:00:00Z", + "end": "2019-08-10T11:00:00Z", + "created": "2019-08-01", + "participant": [ + { + "actor": { + "reference": "Patient/pat014", + "display": "Peter James Chalmers" + }, + "required": "required", + "status": "tentative" + }, + { + "actor": { + "reference": "Practitioner/pra1255", + "display": "Dr Adam Careful" + }, + "required": "required", + "status": "accepted" + } + ], + "requestedPeriod": [ + { + "start": "2020-05-23", + "end": "2020-05-23" + } + ] + }, + "request": { + "method": "PUT", + "url": "Appointment/125" + } + }, + { + "fullUrl": "https://inferno-qa.healthit.gov/reference-server/r4/Appointment/126", + "resource": { + "resourceType": "Appointment", + "id": "126", + "meta": { + "versionId": "1", + "lastUpdated": "2024-05-13T18:49:27.956+00:00", + "source": "#E8EGRTyl0ibnPzrl" + }, + "status": "proposed", + "appointmentType": { + "coding": [ + { + "system": "http://hl7.org/fhir/v2/0276", + "code": "CHECKUP", + "display": "A routine check-up, such as an annual physical" + } + ] + }, + "description": "Regular physical", + "start": "2020-08-01T11:00:00Z", + "end": "2020-08-01T13:00:00Z", + "created": "2019-08-01", + "participant": [ + { + "actor": { + "reference": "Patient/pat014", + "display": "Peter James Chalmers" + }, + "required": "required", + "status": "tentative" + }, + { + "actor": { + "reference": "Practitioner/pra1255", + "display": "Dr Adam Careful" + }, + "required": "required", + "status": "accepted" + } + ], + "requestedPeriod": [ + { + "start": "2021-05-23", + "end": "2021-05-23" + } + ] + }, + "request": { + "method": "PUT", + "url": "Appointment/126" + } + }, + { + "fullUrl": "https://inferno-qa.healthit.gov/reference-server/r4/Practitioner/pra-hfairchild", + "resource": { + "resourceType": "Practitioner", + "id": "pra-hfairchild", + "meta": { + "versionId": "2", + "lastUpdated": "2024-05-13T18:49:28.267+00:00", + "source": "#5kbu1UT6jnpDD9No" + }, + "identifier": [ + { + "system": "http://hl7.org/fhir/sid/us-npi", + "value": "1122334467" + } + ], + "name": [ + { + "use": "official", + "family": "Fairchild", + "given": [ + "Helen" + ], + "prefix": [ + "Ms." + ] + } + ] + }, + "request": { + "method": "PUT", + "url": "Practitioner/pra-hfairchild" + } + }, + { + "fullUrl": "https://inferno-qa.healthit.gov/reference-server/r4/Practitioner/pra1255", + "resource": { + "resourceType": "Practitioner", + "id": "pra1255", + "meta": { + "versionId": "3", + "lastUpdated": "2024-05-14T14:25:02.747+00:00", + "source": "#M62b2hsmczhVXpkZ", + "profile": [ + "http://hl7.org/fhir/us/core/StructureDefinition/us-core-practitioner" + ] + }, + "identifier": [ + { + "system": "http://hl7.org/fhir/sid/us-npi", + "value": "1234567893" + } + ], + "name": [ + { + "family": "Smythe", + "given": [ + "Juliette" + ], + "prefix": [ + "Dr." + ] + } + ], + "telecom": [ + { + "system": "phone", + "value": "781-229-3911" + }, + { + "system": "email", + "value": "juliette.smythe@myhospital.com" + } + ], + "address": [ + { + "use": "home", + "type": "both", + "line": [ + "108 Middlesex Turnpike" + ], + "city": "Burlington", + "state": "MA", + "postalCode": "01803" + } + ] + }, + "request": { + "method": "PUT", + "url": "Practitioner/pra1255" + } + }, + { + "fullUrl": "https://inferno-qa.healthit.gov/reference-server/r4/Patient/pat015", + "resource": { + "resourceType": "Patient", + "id": "pat015", + "meta": { + "versionId": "1", + "lastUpdated": "2024-05-13T17:31:21.364+00:00", + "source": "#mEm9MxVmHGhl52L5" + }, + "text": { + "status": "generated", + "div": "
William Hale Oster OSTER
Identifier0M34355006FW
Address202 Burlington Road
Bedford MA
Date of birth23 February 2015
" + }, + "identifier": [ + { + "use": "usual", + "type": { + "coding": [ + { + "system": "http://terminology.hl7.org/CodeSystem/v2-0203", + "code": "MR" + } + ], + "text": "Medical Record Number" + }, + "system": "http://hl7.org/fhir/sid/us-medicare", + "value": "0M34355006FW" + } + ], + "name": [ + { + "use": "official", + "family": "Oster", + "given": [ + "William", + "Hale", + "Oster" + ] + } + ], + "telecom": [ + { + "system": "phone", + "value": "(781) 555-5555", + "use": "home", + "rank": 1 + }, + { + "system": "phone", + "value": "(781) 555 5613", + "use": "work", + "rank": 2 + }, + { + "system": "phone", + "value": "(781) 555 8834", + "use": "old", + "period": { + "end": "2014" + } + } + ], + "gender": "male", + "birthDate": "2015-02-23", + "address": [ + { + "use": "home", + "type": "both", + "line": [ + "202 Burlington Road" + ], + "city": "Bedford", + "state": "MA", + "postalCode": "01730" + } + ] + }, + "request": { + "method": "PUT", + "url": "Patient/pat015" + } + }, + { + "fullUrl": "https://inferno-qa.healthit.gov/reference-server/r4/Coverage/cov015", + "resource": { + "resourceType": "Coverage", + "id": "cov015", + "meta": { + "versionId": "1", + "lastUpdated": "2024-05-13T18:41:46.837+00:00", + "source": "#1W1rnm3MCWAjmRUn" + }, + "status": "active", + "subscriberId": "10A3D58WH456", + "beneficiary": { + "reference": "Patient/pat015" + }, + "payor": [ + { + "reference": "Organization/org1234" + } + ], + "class": [ + { + "type": { + "coding": [ + { + "system": "http://hl7.org/fhir/coverage-class", + "code": "plan" + } + ] + }, + "value": "Medicare Part A" + } + ] + }, + "request": { + "method": "PUT", + "url": "Coverage/cov015" + } + }, + { + "fullUrl": "https://inferno-qa.healthit.gov/reference-server/r4/Condition/cond015a", + "resource": { + "resourceType": "Condition", + "id": "cond015a", + "meta": { + "versionId": "1", + "lastUpdated": "2024-05-13T18:41:46.945+00:00", + "source": "#rL5lIUbrbWs9eQEF" + }, + "clinicalStatus": { + "coding": [ + { + "system": "http://terminology.hl7.org/CodeSystem/condition-clinical", + "code": "active", + "display": "Active" + } + ] + }, + "verificationStatus": { + "coding": [ + { + "system": "http://terminology.hl7.org/CodeSystem/condition-ver-status", + "code": "confirmed", + "display": "Confirmed" + } + ] + }, + "category": [ + { + "coding": [ + { + "system": "http://hl7.org/fhir/condition-category", + "code": "encounter-diagnosis", + "display": "Encounter Diagnosis" + } + ] + } + ], + "code": { + "coding": [ + { + "system": "http://hl7.org/fhir/sid/icd-10-cm", + "code": "J44.9", + "display": "Chronic obstructive pulmonary disease, unspecified" + } + ] + }, + "subject": { + "reference": "Patient/pat015" + } + }, + "request": { + "method": "PUT", + "url": "Condition/cond015a" + } + }, + { + "fullUrl": "https://inferno-qa.healthit.gov/reference-server/r4/Condition/cond015b", + "resource": { + "resourceType": "Condition", + "id": "cond015b", + "meta": { + "versionId": "1", + "lastUpdated": "2024-05-13T18:41:47.140+00:00", + "source": "#Y45TTgUADoowmMO6" + }, + "clinicalStatus": { + "coding": [ + { + "system": "http://terminology.hl7.org/CodeSystem/condition-clinical", + "code": "active", + "display": "Active" + } + ] + }, + "verificationStatus": { + "coding": [ + { + "system": "http://terminology.hl7.org/CodeSystem/condition-ver-status", + "code": "confirmed", + "display": "Confirmed" + } + ] + }, + "category": [ + { + "coding": [ + { + "system": "http://hl7.org/fhir/condition-category", + "code": "encounter-diagnosis", + "display": "Encounter Diagnosis" + } + ] + } + ], + "code": { + "coding": [ + { + "system": "http://hl7.org/fhir/sid/icd-10-cm", + "code": "R09.02", + "display": "Hypoxemia" + } + ] + }, + "subject": { + "reference": "Patient/pat015" + } + }, + "request": { + "method": "PUT", + "url": "Condition/cond015b" + } + }, + { + "fullUrl": "https://inferno-qa.healthit.gov/reference-server/r4/Condition/cond015c", + "resource": { + "resourceType": "Condition", + "id": "cond015c", + "meta": { + "versionId": "1", + "lastUpdated": "2024-05-13T18:41:47.216+00:00", + "source": "#Si3GCGRb7HZFyMag" + }, + "clinicalStatus": { + "coding": [ + { + "system": "http://terminology.hl7.org/CodeSystem/condition-clinical", + "code": "active", + "display": "Active" + } + ] + }, + "verificationStatus": { + "coding": [ + { + "system": "http://terminology.hl7.org/CodeSystem/condition-ver-status", + "code": "confirmed", + "display": "Confirmed" + } + ] + }, + "category": [ + { + "coding": [ + { + "system": "http://hl7.org/fhir/condition-category", + "code": "encounter-diagnosis", + "display": "Encounter Diagnosis" + } + ] + } + ], + "code": { + "coding": [ + { + "system": "http://hl7.org/fhir/sid/icd-10-cm", + "code": " G30.0", + "display": "Alzheimer's disease with early onset" + } + ] + }, + "subject": { + "reference": "Patient/pat015" + } + }, + "request": { + "method": "PUT", + "url": "Condition/cond015c" + } + }, + { + "fullUrl": "https://inferno-qa.healthit.gov/reference-server/r4/Observation/obs015", + "resource": { + "resourceType": "Observation", + "id": "obs015", + "meta": { + "versionId": "1", + "lastUpdated": "2024-05-13T18:41:47.314+00:00", + "source": "#ZAjCZWOeWkmnX9VE" + }, + "status": "final", + "category": [ + { + "coding": [ + { + "system": "http://terminology.hl7.org/CodeSystem/observation-category", + "code": "vital-signs", + "display": "Vital Signs" + } + ], + "text": "Vital Signs" + } + ], + "code": { + "coding": [ + { + "system": "http://loinc.org", + "code": "59408-5", + "display": "Oxygen saturation in Arterial blood by Pulse oximetry" + } + ] + }, + "subject": { + "reference": "Patient/pat015" + }, + "issued": "2020-03-20T15:30:10+01:00", + "valueQuantity": { + "value": 91, + "unit": "%", + "system": "http://unitsofmeasure.org", + "code": "%" + }, + "interpretation": [ + { + "coding": [ + { + "system": "http://terminology.hl7.org/CodeSystem/v2-0078", + "code": "N", + "display": "Normal" + } + ], + "text": "Normal (applies to non-numeric results)" + } + ], + "referenceRange": [ + { + "low": { + "value": 90, + "unit": "%", + "system": "http://unitsofmeasure.org", + "code": "%" + }, + "high": { + "value": 99, + "unit": "%", + "system": "http://unitsofmeasure.org", + "code": "%" + } + } + ] + }, + "request": { + "method": "PUT", + "url": "Observation/obs015" + } + }, + { + "fullUrl": "https://inferno-qa.healthit.gov/reference-server/r4/Observation/obs015B", + "resource": { + "resourceType": "Observation", + "id": "obs015B", + "meta": { + "versionId": "1", + "lastUpdated": "2024-05-13T18:41:47.416+00:00", + "source": "#SURo5kwhT04xOzUn" + }, + "status": "final", + "category": [ + { + "coding": [ + { + "system": "http://terminology.hl7.org/CodeSystem/observation-category", + "code": "laboratory", + "display": "laboratory" + } + ], + "text": "Laboratory" + } + ], + "code": { + "coding": [ + { + "system": "http://loinc.org", + "code": "31100-1", + "display": "Hematocrit [Volume Fraction] of Blood by Impedance" + } + ] + }, + "subject": { + "reference": "Patient/pat015" + }, + "issued": "2020-03-20T15:30:10+01:00", + "valueQuantity": { + "value": 69, + "unit": "%", + "system": "http://unitsofmeasure.org", + "code": "%" + }, + "interpretation": [ + { + "coding": [ + { + "system": "http://terminology.hl7.org/CodeSystem/v2-0078", + "code": "H", + "display": "High" + } + ] + } + ], + "referenceRange": [ + { + "low": { + "value": 42, + "unit": "%", + "system": "http://unitsofmeasure.org", + "code": "%" + }, + "high": { + "value": 54, + "unit": "%", + "system": "http://unitsofmeasure.org", + "code": "%" + } + } + ] + }, + "request": { + "method": "PUT", + "url": "Observation/obs015B" + } + }, + { + "fullUrl": "https://inferno-qa.healthit.gov/reference-server/r4/Encounter/pat015-rad-encounter", + "resource": { + "resourceType": "Encounter", + "id": "pat015-rad-encounter", + "meta": { + "versionId": "2", + "lastUpdated": "2024-05-13T18:41:48.687+00:00", + "source": "#7mpydc9Fv22P0wWg" + }, + "status": "finished", + "class": { + "system": "http://terminology.hl7.org/CodeSystem/v3-ActCode", + "code": "HH", + "display": "home health" + }, + "type": [ + { + "coding": [ + { + "system": "http://snomed.info/sct", + "code": "185345009", + "display": "Encounter for symptom" + } + ] + } + ], + "priority": { + "coding": [ + { + "system": "http://snomed.info/sct", + "code": "709122007", + "display": "As soon as possible (qualifier value)" + } + ] + }, + "subject": { + "reference": "Patient/pat015", + "display": "Roosevelt Theodore" + }, + "participant": [ + { + "type": [ + { + "coding": [ + { + "system": "http://terminology.hl7.org/CodeSystem/v3-ParticipationType", + "code": "PPRF", + "display": "primary performer" + } + ] + } + ], + "individual": { + "reference": "Practitioner/pra1234", + "display": "Dr. Jane Doe" + } + } + ], + "period": { + "start": "2020-07-01T10:40:10+01:00", + "end": "2020-07-01T12:40:10+01:00" + }, + "length": { + "value": 56, + "unit": "minutes", + "system": "http://unitsofmeasure.org", + "code": "min" + }, + "reasonCode": [ + { + "coding": [ + { + "system": "http://hl7.org/fhir/sid/icd-10-cm", + "code": "J44.9", + "display": "Chronic obstructive pulmonary disease, unspecified" + } + ] + } + ], + "diagnosis": [ + { + "condition": { + "reference": "Condition/cond015a", + "display": "The patient is hospitalized for stroke" + }, + "use": { + "coding": [ + { + "system": "http://terminology.hl7.org/CodeSystem/diagnosis-role", + "code": "AD", + "display": "Admission diagnosis" + } + ] + }, + "rank": 2 + }, + { + "condition": { + "reference": "Condition/cond015a", + "display": "The patient is hospitalized for lung condition" + }, + "use": { + "coding": [ + { + "system": "http://terminology.hl7.org/CodeSystem/diagnosis-role", + "code": "CC", + "display": "Chief complaint" + } + ] + }, + "rank": 1 + } + ] + }, + "request": { + "method": "PUT", + "url": "Encounter/pat015-rad-encounter" + } + }, + { + "fullUrl": "https://inferno-qa.healthit.gov/reference-server/r4/Observation/obs015-hco3", + "resource": { + "resourceType": "Observation", + "id": "obs015-hco3", + "meta": { + "versionId": "1", + "lastUpdated": "2024-05-13T18:41:47.494+00:00", + "source": "#O0VSX7Jc90xAWK8j" + }, + "status": "final", + "category": [ + { + "coding": [ + { + "system": "http://terminology.hl7.org/CodeSystem/observation-category", + "code": "laboratory", + "display": "Laboratory" + } + ], + "text": "Laboratory" + } + ], + "code": { + "coding": [ + { + "system": "http://loinc.org", + "code": "1960-4", + "display": "HCO3 BldA-sCnc" + } + ] + }, + "subject": { + "reference": "Patient/pat015" + }, + "encounter": { + "reference": "Encounter/pat015-rad-encounter" + }, + "issued": "2020-07-01T15:30:10+01:00", + "performer": [ + { + "reference": "Practitioner/pra-dmorgan", + "type": "Practitioner", + "display": "Dexter Morgan" + }, + { + "reference": "Organization/org-lab", + "type": "Organization", + "display": "Gulf Coast Lab" + } + ], + "valueQuantity": { + "value": 32, + "unit": "mmol/L", + "system": "http://unitsofmeasure.org", + "code": "mmol/L" + }, + "referenceRange": [ + { + "low": { + "value": 23, + "unit": "mmol/L", + "system": "http://unitsofmeasure.org", + "code": "mmol/L" + }, + "high": { + "value": 30, + "unit": "mmol/L", + "system": "http://unitsofmeasure.org", + "code": "mmol/L" + } + } + ] + }, + "request": { + "method": "PUT", + "url": "Observation/obs015-hco3" + } + }, + { + "fullUrl": "https://inferno-qa.healthit.gov/reference-server/r4/Observation/obs015-o2sat-overnight", + "resource": { + "resourceType": "Observation", + "id": "obs015-o2sat-overnight", + "meta": { + "versionId": "1", + "lastUpdated": "2024-05-13T18:41:47.623+00:00", + "source": "#ijw3mqoFPQNSBjBk" + }, + "status": "final", + "category": [ + { + "coding": [ + { + "system": "http://terminology.hl7.org/CodeSystem/observation-category", + "code": "laboratory", + "display": "Laboratory" + } + ], + "text": "Laboratory" + } + ], + "code": { + "coding": [ + { + "system": "http://snomed.info/sct", + "code": "252568001", + "display": "Overnight pulse oximetry (procedure)" + } + ] + }, + "subject": { + "reference": "Patient/pat015" + }, + "encounter": { + "reference": "Encounter/pat015-rad-encounter" + }, + "issued": "2020-06-15T15:30:10+01:00", + "performer": [ + { + "reference": "Organization/org-lab", + "type": "Organization", + "display": "Clinical Lab" + } + ], + "valueQuantity": { + "value": 90, + "unit": "%", + "system": "http://unitsofmeasure.org", + "code": "%" + } + }, + "request": { + "method": "PUT", + "url": "Observation/obs015-o2sat-overnight" + } + }, + { + "fullUrl": "https://inferno-qa.healthit.gov/reference-server/r4/Observation/obs015-o2sat-resting", + "resource": { + "resourceType": "Observation", + "id": "obs015-o2sat-resting", + "meta": { + "versionId": "1", + "lastUpdated": "2024-05-13T18:41:47.712+00:00", + "source": "#YxUCcR3S5wr8s0AO" + }, + "status": "final", + "category": [ + { + "coding": [ + { + "system": "http://terminology.hl7.org/CodeSystem/observation-category", + "code": "laboratory", + "display": "Laboratory" + } + ], + "text": "Laboratory" + } + ], + "code": { + "coding": [ + { + "system": "http://loinc.org", + "code": "59417-6", + "display": "Oxygen saturation in Arterial blood by Pulse oximetry --resting" + } + ] + }, + "subject": { + "reference": "Patient/pat015" + }, + "encounter": { + "reference": "Encounter/pat015-rad-encounter" + }, + "issued": "2020-07-01T15:30:10+01:00", + "performer": [ + { + "reference": "Practitioner/pra-dmorgan", + "type": "Practitioner", + "display": "Dexter Morgan" + }, + { + "reference": "Organization/org1234", + "type": "Organization", + "display": "Gulf Coast Lab" + } + ], + "valueQuantity": { + "value": 95, + "unit": "%", + "system": "http://unitsofmeasure.org", + "code": "%" + } + }, + "request": { + "method": "PUT", + "url": "Observation/obs015-o2sat-resting" + } + }, + { + "fullUrl": "https://inferno-qa.healthit.gov/reference-server/r4/Observation/obs015-o2sat-treatment", + "resource": { + "resourceType": "Observation", + "id": "obs015-o2sat-treatment", + "meta": { + "versionId": "1", + "lastUpdated": "2024-05-13T18:41:47.808+00:00", + "source": "#dQeLpxWubjwetneg" + }, + "status": "final", + "category": [ + { + "coding": [ + { + "system": "http://terminology.hl7.org/CodeSystem/observation-category", + "code": "laboratory", + "display": "Laboratory" + } + ], + "text": "Laboratory" + } + ], + "code": { + "coding": [ + { + "system": "http://loinc.org", + "code": "59409-3", + "display": "Oxygen saturation in Arterial blood by Pulse oximetry --during treatment" + } + ] + }, + "subject": { + "reference": "Patient/pat015" + }, + "encounter": { + "reference": "Encounter/pat015-rad-encounter" + }, + "issued": "2020-07-01T15:30:10+01:00", + "performer": [ + { + "reference": "Practitioner/pra-dmorgan", + "type": "Practitioner", + "display": "Dexter Morgan" + }, + { + "reference": "Organization/org1234", + "type": "Organization", + "display": "Gulf Coast Lab" + } + ], + "valueQuantity": { + "value": 97, + "unit": "%", + "system": "http://unitsofmeasure.org", + "code": "%" + } + }, + "request": { + "method": "PUT", + "url": "Observation/obs015-o2sat-treatment" + } + }, + { + "fullUrl": "https://inferno-qa.healthit.gov/reference-server/r4/Observation/obs-pat015-pao2", + "resource": { + "resourceType": "Observation", + "id": "obs-pat015-pao2", + "meta": { + "versionId": "1", + "lastUpdated": "2024-05-13T18:41:47.884+00:00", + "source": "#QtjuazDV4ywPg5Wg" + }, + "status": "final", + "category": [ + { + "coding": [ + { + "system": "http://terminology.hl7.org/CodeSystem/observation-category", + "code": "laboratory", + "display": "Laboratory" + } + ], + "text": "Laboratory" + } + ], + "code": { + "coding": [ + { + "system": "http://loinc.org", + "code": "2703-7", + "display": "Oxygen (BldA) [Partial pressure]" + } + ] + }, + "subject": { + "reference": "Patient/pat015" + }, + "issued": "2020-06-15T15:30:10+01:00", + "valueQuantity": { + "value": 65, + "unit": "mm[Hg]", + "system": "http://unitsofmeasure.org", + "code": "mm[Hg]" + }, + "interpretation": [ + { + "coding": [ + { + "system": "http://terminology.hl7.org/CodeSystem/v2-0078", + "code": "L", + "display": "LOW" + } + ], + "text": "Normal (applies to non-numeric results)" + } + ], + "referenceRange": [ + { + "low": { + "value": 75, + "unit": "mm[Hg]", + "system": "http://unitsofmeasure.org", + "code": "mm[Hg]" + }, + "high": { + "value": 100, + "unit": "mm[Hg]", + "system": "http://unitsofmeasure.org", + "code": "mm[Hg]" + } + } + ] + }, + "request": { + "method": "PUT", + "url": "Observation/obs-pat015-pao2" + } + }, + { + "fullUrl": "https://inferno-qa.healthit.gov/reference-server/r4/Observation/obs-pat015-o2excercise", + "resource": { + "resourceType": "Observation", + "id": "obs-pat015-o2excercise", + "meta": { + "versionId": "1", + "lastUpdated": "2024-05-13T18:41:47.988+00:00", + "source": "#0c7NNRXAsTYRaOpx" + }, + "status": "final", + "category": [ + { + "coding": [ + { + "system": "http://terminology.hl7.org/CodeSystem/observation-category", + "code": "vital-signs", + "display": "Vital Signs" + } + ], + "text": "Vital Signs" + } + ], + "code": { + "coding": [ + { + "system": "http://loinc.org", + "code": "89276-0", + "display": "Oxygen saturation with exercise" + } + ] + }, + "subject": { + "reference": "Patient/pat015" + }, + "issued": "2020-03-20T15:30:10+01:00", + "valueQuantity": { + "value": 80, + "unit": "%", + "system": "http://unitsofmeasure.org", + "code": "%" + }, + "interpretation": [ + { + "coding": [ + { + "system": "http://terminology.hl7.org/CodeSystem/v2-0078", + "code": "L", + "display": "LOW" + } + ], + "text": "Normal (applies to non-numeric results)" + } + ], + "referenceRange": [ + { + "low": { + "value": 90, + "unit": "%", + "system": "http://unitsofmeasure.org", + "code": "%" + }, + "high": { + "value": 99, + "unit": "%", + "system": "http://unitsofmeasure.org", + "code": "%" + } + } + ] + }, + "request": { + "method": "PUT", + "url": "Observation/obs-pat015-o2excercise" + } + }, + { + "fullUrl": "https://inferno-qa.healthit.gov/reference-server/r4/Observation/obs015-paco2", + "resource": { + "resourceType": "Observation", + "id": "obs015-paco2", + "meta": { + "versionId": "1", + "lastUpdated": "2024-05-13T18:41:48.096+00:00", + "source": "#pVL3MD4587KKl4y6" + }, + "status": "final", + "category": [ + { + "coding": [ + { + "system": "http://terminology.hl7.org/CodeSystem/observation-category", + "code": "laboratory", + "display": "Laboratory" + } + ], + "text": "Laboratory" + } + ], + "code": { + "coding": [ + { + "system": "http://loinc.org", + "code": "2019-8", + "display": "Carbon dioxide [Partial pressure] in Arterial blood" + } + ] + }, + "subject": { + "reference": "Patient/pat015" + }, + "encounter": { + "reference": "Encounter/pat015-rad-encounter" + }, + "issued": "2020-06-15T15:30:10+01:00", + "performer": [ + { + "reference": "Practitioner/pra-dmorgan", + "type": "Practitioner", + "display": "Dexter Morgan" + }, + { + "reference": "Organization/org-lab", + "type": "Organization", + "display": "Gulf Coast Lab" + } + ], + "valueQuantity": { + "value": 45, + "unit": "mm[Hg]", + "system": "http://unitsofmeasure.org", + "code": "mm[Hg]" + }, + "referenceRange": [ + { + "low": { + "value": 38, + "unit": "mm[Hg]", + "system": "http://unitsofmeasure.org", + "code": "mm[Hg]" + }, + "high": { + "value": 42, + "unit": "mm[Hg]", + "system": "http://unitsofmeasure.org", + "code": "mm[Hg]" + } + } + ] + }, + "request": { + "method": "PUT", + "url": "Observation/obs015-paco2" + } + }, + { + "fullUrl": "https://inferno-qa.healthit.gov/reference-server/r4/Practitioner/pra-dmorgan", + "resource": { + "resourceType": "Practitioner", + "id": "pra-dmorgan", + "meta": { + "versionId": "1", + "lastUpdated": "2024-05-13T18:41:47.499+00:00", + "source": "#O0VSX7Jc90xAWK8j" + }, + "extension": [ + { + "url": "http://hapifhir.io/fhir/StructureDefinition/resource-placeholder", + "valueBoolean": true + } + ] + }, + "request": { + "method": "PUT", + "url": "Practitioner/pra-dmorgan" + } + }, + { + "fullUrl": "https://inferno-qa.healthit.gov/reference-server/r4/Organization/org-lab", + "resource": { + "resourceType": "Organization", + "id": "org-lab", + "meta": { + "versionId": "2", + "lastUpdated": "2024-05-13T22:12:20.671+00:00", + "source": "#sNhvYZyDMlMJgAXM" + }, + "identifier": [ + { + "system": "http://www.acme.org.au/units", + "value": "ClinLab" + } + ], + "name": "Clinical Lab", + "telecom": [ + { + "system": "phone", + "value": "+1 555 234 1234", + "use": "work" + }, + { + "system": "email", + "value": "contact@labs.acme.org", + "use": "work" + } + ] + }, + "request": { + "method": "PUT", + "url": "Organization/org-lab" + } + }, + { + "fullUrl": "https://inferno-qa.healthit.gov/reference-server/r4/Observation/obs015-ph", + "resource": { + "resourceType": "Observation", + "id": "obs015-ph", + "meta": { + "versionId": "1", + "lastUpdated": "2024-05-13T18:41:48.195+00:00", + "source": "#fcDJw5t7ZWg8ujl1" + }, + "status": "final", + "category": [ + { + "coding": [ + { + "system": "http://terminology.hl7.org/CodeSystem/observation-category", + "code": "laboratory", + "display": "Laboratory" + } + ], + "text": "Laboratory" + } + ], + "code": { + "coding": [ + { + "system": "http://loinc.org", + "code": "2744-1", + "display": "pH of Arterial blood" + } + ] + }, + "subject": { + "reference": "Patient/pat015" + }, + "encounter": { + "reference": "Encounter/pat015-rad-encounter" + }, + "issued": "2020-06-15T15:30:10+01:00", + "performer": [ + { + "reference": "Practitioner/pra-dmorgan", + "type": "Practitioner", + "display": "Dexter Morgan" + }, + { + "reference": "Organization/org-lab", + "type": "Organization", + "display": "Gulf Coast Lab" + } + ], + "valueQuantity": { + "value": 7.33, + "unit": "pH", + "system": "http://unitsofmeasure.org", + "code": "pH" + } + }, + "request": { + "method": "PUT", + "url": "Observation/obs015-ph" + } + }, + { + "fullUrl": "https://inferno-qa.healthit.gov/reference-server/r4/DeviceRequest/devreq-015-e0250", + "resource": { + "resourceType": "DeviceRequest", + "id": "devreq-015-e0250", + "meta": { + "versionId": "1", + "lastUpdated": "2024-05-13T18:41:48.266+00:00", + "source": "#UUx65vQyrJuotcqp", + "profile": [ + "http://hl7.org/fhir/us/davinci-crd/R4/StructureDefinition/profile-devicerequest-r4" + ] + }, + "identifier": [ + { + "type": { + "coding": [ + { + "system": "http://terminology.hl7.org/CodeSystem/v2-0203", + "code": "PLAC" + } + ] + }, + "value": "be5ed14f-ed52-4a5a-ba94-29f58b14a585" + } + ], + "status": "draft", + "intent": "original-order", + "codeCodeableConcept": { + "coding": [ + { + "system": "https://bluebutton.cms.gov/resources/codesystem/hcpcs", + "code": "E0250", + "display": "Hospital bed fixed height with any type of side rails, mattress" + } + ] + }, + "subject": { + "reference": "Patient/pat015" + }, + "authoredOn": "2023-01-01T00:00:00Z", + "requester": { + "reference": "Practitioner/pra-hfairchild" + }, + "performer": { + "reference": "Practitioner/pra1234" + }, + "insurance": [ + { + "reference": "Coverage/cov015" + } + ] + }, + "request": { + "method": "PUT", + "url": "DeviceRequest/devreq-015-e0250" + } + }, + { + "fullUrl": "https://inferno-qa.healthit.gov/reference-server/r4/DeviceRequest/devreq015", + "resource": { + "resourceType": "DeviceRequest", + "id": "devreq015", + "meta": { + "versionId": "1", + "lastUpdated": "2024-05-13T18:41:48.396+00:00", + "source": "#lSS21FKLsnBGm7q7", + "profile": [ + "http://hl7.org/fhir/us/davinci-crd/R4/StructureDefinition/profile-devicerequest-r4" + ] + }, + "identifier": [ + { + "type": { + "coding": [ + { + "system": "http://terminology.hl7.org/CodeSystem/v2-0203", + "code": "PLAC" + } + ] + }, + "value": "f0fc0619-976e-4e58-a790-a296781d7558" + } + ], + "status": "draft", + "intent": "original-order", + "codeCodeableConcept": { + "coding": [ + { + "system": "https://bluebutton.cms.gov/resources/codesystem/hcpcs", + "code": "E0424", + "display": "Stationary Compressed Gaseous Oxygen System, Rental" + } + ] + }, + "subject": { + "reference": "Patient/pat015" + }, + "occurrenceTiming": { + "repeat": { + "boundsDuration": { + "value": 8, + "unit": "mo", + "system": "http://unitsofmeasure.org", + "code": "mo" + } + }, + "code": { + "text": "During sleep AND During exertion" + } + }, + "authoredOn": "2023-01-01T00:00:00Z", + "requester": { + "reference": "Practitioner/pra-hfairchild" + }, + "performer": { + "reference": "Practitioner/pra1234" + }, + "insurance": [ + { + "reference": "Coverage/cov015" + } + ] + }, + "request": { + "method": "PUT", + "url": "DeviceRequest/devreq015" + } + }, + { + "fullUrl": "https://inferno-qa.healthit.gov/reference-server/r4/DeviceRequest/devreqe0470", + "resource": { + "resourceType": "DeviceRequest", + "id": "devreqe0470", + "meta": { + "versionId": "1", + "lastUpdated": "2024-05-13T18:41:48.491+00:00", + "source": "#5ErZ3XnHvR7T8NQp", + "profile": [ + "http://hl7.org/fhir/us/davinci-crd/StructureDefinition/profile-devicerequest-r4" + ] + }, + "identifier": [ + { + "type": { + "coding": [ + { + "system": "http://terminology.hl7.org/CodeSystem/v2-0203", + "code": "PLAC" + } + ] + }, + "value": "ba5b69af-85c5-4f11-8dff-c4cbf6aa0281" + } + ], + "instantiatesCanonical": [ + "http://hapi.fhir.org/baseR4/PlanDefinition/1430" + ], + "status": "draft", + "intent": "original-order", + "codeCodeableConcept": { + "coding": [ + { + "system": "https://bluebutton.cms.gov/resources/codesystem/hcpcs", + "code": "E0470", + "display": "Respiratory Assist Device" + } + ] + }, + "subject": { + "reference": "Patient/pat015" + }, + "encounter": { + "reference": "Encounter/pat015-rad-encounter" + }, + "authoredOn": "2020-03-08", + "requester": { + "reference": "Practitioner/pra1234" + }, + "performer": { + "reference": "Practitioner/pra1234" + }, + "insurance": [ + { + "reference": "Coverage/cov015" + } + ] + }, + "request": { + "method": "PUT", + "url": "DeviceRequest/devreqe0470" + } + }, + { + "fullUrl": "https://inferno-qa.healthit.gov/reference-server/r4/ServiceRequest/servreq-g0180-1", + "resource": { + "resourceType": "ServiceRequest", + "id": "servreq-g0180-1", + "meta": { + "versionId": "1", + "lastUpdated": "2024-05-13T18:41:48.591+00:00", + "source": "#0Y8tfp6tzIxtOit6" + }, + "identifier": [ + { + "type": { + "coding": [ + { + "system": "http://terminology.hl7.org/CodeSystem/v2-0203", + "code": "PLAC" + } + ] + }, + "value": "11ddab7e-1488-4848-b3d5-512d2f1e3f28" + } + ], + "status": "draft", + "intent": "order", + "code": { + "coding": [ + { + "system": "https://bluebutton.cms.gov/resources/codesystem/hcpcs", + "code": "G0180", + "display": "Medicare-covered home health services under a home health plan of care" + } + ] + }, + "subject": { + "reference": "Patient/pat015" + }, + "occurrenceDateTime": "2017-10-01", + "authoredOn": "2017-10-04", + "requester": { + "display": "Smythe Juliette, MD" + }, + "performer": [ + { + "reference": "Practitioner/pra1255" + } + ], + "insurance": [ + { + "reference": "Coverage/cov016" + } + ] + }, + "request": { + "method": "PUT", + "url": "ServiceRequest/servreq-g0180-1" + } + }, + { + "fullUrl": "https://inferno-qa.healthit.gov/reference-server/r4/Observation/pat015-hemocrit", + "resource": { + "resourceType": "Observation", + "id": "pat015-hemocrit", + "meta": { + "versionId": "1", + "lastUpdated": "2024-05-13T22:12:19.855+00:00", + "source": "#dwCZ5COJ1YW55Eig" + }, + "status": "final", + "category": [ + { + "coding": [ + { + "system": "http://terminology.hl7.org/CodeSystem/observation-category", + "code": "laboratory", + "display": "laboratory" + } + ], + "text": "Laboratory" + } + ], + "code": { + "coding": [ + { + "system": "http://loinc.org", + "code": "32354-3", + "display": "Hct VFr BldA" + } + ] + }, + "subject": { + "reference": "Patient/pat015" + }, + "issued": "2020-06-15T15:30:10+01:00", + "valueQuantity": { + "value": 72, + "unit": "%", + "system": "http://unitsofmeasure.org", + "code": "%" + } + }, + "request": { + "method": "PUT", + "url": "Observation/pat015-hemocrit" + } + }, + { + "fullUrl": "https://inferno-qa.healthit.gov/reference-server/r4/Appointment/124", + "resource": { + "resourceType": "Appointment", + "id": "124", + "meta": { + "versionId": "1", + "lastUpdated": "2024-05-13T22:12:20.079+00:00", + "source": "#9Q01dEEfPR249KjL" + }, + "text": { + "status": "generated", + "div": "
" + }, + "status": "proposed", + "serviceCategory": [ + { + "coding": [ + { + "system": "http://terminology.hl7.org/CodeSystem/service-category", + "code": "17", + "display": "General Practice" + } + ] + } + ], + "serviceType": [ + { + "coding": [ + { + "system": "http://terminology.hl7.org/CodeSystem/service-type", + "code": "pat015", + "display": "General Practice" + } + ] + } + ], + "specialty": [ + { + "coding": [ + { + "system": "http://snomed.info/sct", + "code": "394814009", + "display": "General practice (specialty)" + } + ] + } + ], + "appointmentType": { + "coding": [ + { + "system": "http://terminology.hl7.org/CodeSystem/v2-0276", + "code": "FOLLOWUP", + "display": "A follow up visit from a previous appointment" + } + ] + }, + "reasonReference": [ + { + "reference": "Condition/cond015a", + "display": "Heart problem" + } + ], + "priority": 5, + "description": "Discussion on the results of your recent MRI", + "start": "2013-12-10T09:00:00Z", + "end": "2013-12-10T11:00:00Z", + "created": "2013-10-10", + "comment": "Further expand on the results of the MRI and determine the next actions that may be appropriate.", + "basedOn": [ + { + "reference": "ServiceRequest/servreq-g0180-1" + } + ], + "participant": [ + { + "actor": { + "reference": "Patient/pat015", + "display": "Amy Baxter" + }, + "required": "required", + "status": "accepted" + }, + { + "type": [ + { + "coding": [ + { + "system": "http://terminology.hl7.org/CodeSystem/v3-ParticipationType", + "code": "ATND" + } + ] + } + ], + "actor": { + "reference": "Practitioner/pra1255", + "display": "Dr Adam Careful" + }, + "required": "required", + "status": "accepted" + }, + { + "actor": { + "reference": "Location/loc1234", + "display": "South Wing, second floor" + }, + "required": "required", + "status": "accepted" + } + ], + "requestedPeriod": [ + { + "start": "2020-11-01", + "end": "2020-12-15" + } + ] + }, + "request": { + "method": "PUT", + "url": "Appointment/124" + } + }, + { + "fullUrl": "https://inferno-qa.healthit.gov/reference-server/r4/Encounter/127", + "resource": { + "resourceType": "Encounter", + "id": "127", + "meta": { + "versionId": "1", + "lastUpdated": "2024-05-13T22:12:20.185+00:00", + "source": "#tJqP1EXPSK6HyxAC" + }, + "identifier": [ + { + "use": "official", + "system": "http://www.amc.nl/zorgportal/identifiers/visits", + "value": "v1451" + } + ], + "status": "in-progress", + "class": { + "system": "http://terminology.hl7.org/CodeSystem/v3-ActCode", + "code": "AMB", + "display": "ambulatory" + }, + "type": [ + { + "coding": [ + { + "system": "http://snomed.info/sct", + "code": "270427003", + "display": "Patient-initiated encounter" + } + ] + } + ], + "priority": { + "coding": [ + { + "system": "http://snomed.info/sct", + "code": "310361003", + "display": "Non-urgent cardiological admission" + } + ] + }, + "subject": { + "reference": "Patient/pat015" + }, + "participant": [ + { + "individual": { + "reference": "Practitioner/pra1255" + } + } + ], + "length": { + "value": 140, + "unit": "min", + "system": "http://unitsofmeasure.org", + "code": "min" + }, + "reasonCode": [ + { + "coding": [ + { + "system": "http://snomed.info/sct", + "code": "34068001", + "display": "Heart valve replacement" + } + ] + } + ], + "hospitalization": { + "preAdmissionIdentifier": { + "use": "official", + "system": "http://www.amc.nl/zorgportal/identifiers/pre-admissions", + "value": "93042" + }, + "admitSource": { + "coding": [ + { + "system": "http://snomed.info/sct", + "code": "305956004", + "display": "Referral by physician" + } + ] + }, + "dischargeDisposition": { + "coding": [ + { + "system": "http://snomed.info/sct", + "code": "306689006", + "display": "Discharge to home" + } + ] + } + }, + "serviceProvider": { + "reference": "Organization/org123", + "display": "University Medical Center" + } + }, + "request": { + "method": "PUT", + "url": "Encounter/127" + } + }, + { + "fullUrl": "https://inferno-qa.healthit.gov/reference-server/r4/Location/loc1234", + "resource": { + "resourceType": "Location", + "id": "loc1234", + "meta": { + "versionId": "2", + "lastUpdated": "2024-05-13T22:12:20.557+00:00", + "source": "#mgw17imsWJuB6ayP" + }, + "address": { + "line": [ + "100 Good St" + ], + "city": "Bedford", + "state": "MA", + "postalCode": "01730" + } + }, + "request": { + "method": "PUT", + "url": "Location/loc1234" + } + }, + { + "fullUrl": "https://inferno-qa.healthit.gov/reference-server/r4/Organization/org123", + "resource": { + "resourceType": "Organization", + "id": "org123", + "meta": { + "versionId": "1", + "lastUpdated": "2024-05-13T22:12:20.191+00:00", + "source": "#tJqP1EXPSK6HyxAC" + }, + "extension": [ + { + "url": "http://hapifhir.io/fhir/StructureDefinition/resource-placeholder", + "valueBoolean": true + } + ] + }, + "request": { + "method": "PUT", + "url": "Organization/org123" + } + } + ] +} \ No newline at end of file diff --git a/run.sh b/run.sh new file mode 100755 index 0000000..fde0a7e --- /dev/null +++ b/run.sh @@ -0,0 +1,3 @@ +#!/bin/sh +docker compose build +docker compose up diff --git a/setup.sh b/setup.sh new file mode 100755 index 0000000..6089f58 --- /dev/null +++ b/setup.sh @@ -0,0 +1,4 @@ +#!/bin/sh +docker compose pull +docker compose build +docker compose run inferno bundle exec inferno migrate diff --git a/spec/davinci_crd_test_kit/additional_orders_validation_test_spec.rb b/spec/davinci_crd_test_kit/additional_orders_validation_test_spec.rb new file mode 100644 index 0000000..a8fd039 --- /dev/null +++ b/spec/davinci_crd_test_kit/additional_orders_validation_test_spec.rb @@ -0,0 +1,62 @@ +RSpec.describe DaVinciCRDTestKit::AdditionalOrdersValidationTest do + let(:runnable) { Inferno::Repositories::Tests.new.find('crd_additional_orders_card_validation') } + let(:session_data_repo) { Inferno::Repositories::SessionData.new } + let(:results_repo) { Inferno::Repositories::Results.new } + let(:test_session) { repo_create(:test_session, test_suite_id: 'crd_server') } + let(:valid_cards) do + json = File.read(File.join(__dir__, '..', 'fixtures', 'valid_cards.json')) + JSON.parse(json) + end + let(:cards_with_suggestions) { valid_cards.filter { |card| card['suggestions'].present? } } + + def run(runnable, inputs = {}) + test_run_params = { test_session_id: test_session.id }.merge(runnable.reference_hash) + test_run = Inferno::Repositories::TestRuns.new.create(test_run_params) + inputs.each do |name, value| + session_data_repo.save( + test_session_id: test_session.id, + name:, + value:, + type: runnable.config.input_type(name) + ) + end + Inferno::TestRunner.new(test_session:, test_run:).run(runnable) + end + + def entity_result_message + results_repo.current_results_for_test_session_and_runnables(test_session.id, [runnable]) + .first + .messages + .first + end + + before do + allow_any_instance_of(runnable).to receive(:resource_is_valid?).and_return(true) + end + + it 'passes if valid additional orders as companions cards are received' do + result = run(runnable, valid_cards_with_suggestions: cards_with_suggestions.to_json) + expect(result.result).to eq('pass') + end + + it 'skips if valid_cards_with_suggestions not present' do + result = run(runnable) + expect(result.result).to eq('skip') + expect(result.result_message).to match(/'valid_cards_with_suggestions' is nil, skipping test/) + end + + it 'fails if valid_cards_with_suggestions is not valid json' do + result = run(runnable, valid_cards_with_suggestions: '[[') + expect(result.result).to eq('fail') + expect(result.result_message).to match(/Invalid JSON/) + end + + it 'skips if no additional orders as companions card present' do + dup_cards = cards_with_suggestions.deep_dup + dup_cards.reject! { |card| card['summary'].include?('Additional Orders As Companions') } + + result = run(runnable, valid_cards_with_suggestions: dup_cards.to_json) + expect(result.result).to eq('skip') + expect(result.result_message).to match(%r{does not contain an Additional Orders as companions/prerequisites card}) + end +end diff --git a/spec/davinci_crd_test_kit/appointment_book_receive_request_test_spec.rb b/spec/davinci_crd_test_kit/appointment_book_receive_request_test_spec.rb new file mode 100644 index 0000000..19ce8e1 --- /dev/null +++ b/spec/davinci_crd_test_kit/appointment_book_receive_request_test_spec.rb @@ -0,0 +1,284 @@ +require_relative '../../lib/davinci_crd_test_kit/client_tests/appointment_book_receive_request_test' +require_relative '../request_helper' + +RSpec.describe DaVinciCRDTestKit::AppointmentBookReceiveRequestTest do + include Rack::Test::Methods + include RequestHelpers + + let(:suite) { Inferno::Repositories::TestSuites.new.find('crd_client') } + let(:test) { Inferno::Repositories::Tests.new.find('crd_appointment_book_request') } + let(:session_data_repo) { Inferno::Repositories::SessionData.new } + let(:results_repo) { Inferno::Repositories::Results.new } + let(:test_session) { repo_create(:test_session, test_suite_id: 'crd_client') } + let(:jwt_helper) { Class.new(DaVinciCRDTestKit::JwtHelper) } + + let(:example_client_url) { 'https://cds.example.org' } + let(:base_url) { "#{Inferno::Application['base_url']}/custom/crd_client" } + let(:appointment_book_url) { "#{base_url}/cds-services/appointment-book-service" } + let(:client_fhir_server) { 'https://example/r4' } + let(:patient_id) { 'example' } + let(:appointment_book_selected_response_types) { ['instructions', 'coverage_information', 'external_reference'] } + + let(:server_endpoint) { '/custom/crd_client/cds-services/appointment-book-service' } + let(:body_json) do + File.read(File.join( + __dir__, '..', 'fixtures', 'appointment_book_hook_request.json' + )) + end + let(:body) do + JSON.parse(File.read(File.join( + __dir__, '..', 'fixtures', 'appointment_book_hook_request.json' + ))) + end + + let(:crd_coverage) do + JSON.parse(File.read(File.join( + __dir__, '..', 'fixtures', 'crd_coverage_example.json' + ))) + end + let(:crd_coverage_bundle) do + bundle = FHIR::Bundle.new(type: 'searchset') + bundle.entry.append(FHIR::Bundle::Entry.new( + fullUrl: 'https://example.com/base/Coverage/coverage_example', + resource: FHIR.from_contents(crd_coverage.to_json) + )) + bundle + end + + def run(runnable, inputs = {}) + test_run_params = { test_session_id: test_session.id }.merge(runnable.reference_hash) + test_run = Inferno::Repositories::TestRuns.new.create(test_run_params) + inputs.each do |name, value| + session_data_repo.save( + test_session_id: test_session.id, + name:, + value:, + type: runnable.config.input_type(name) + ) + end + Inferno::TestRunner.new(test_session:, test_run:).run(runnable) + end + + it 'passes and responds 200 if request sent to the provided URL and jwt `iss` claim matches the given`iss`' do + allow(test).to receive(:suite).and_return(suite) + + token = jwt_helper.build( + aud: appointment_book_url, + iss: example_client_url, + jku: "#{example_client_url}/jwks.json", + encryption_method: 'RS384' + ) + + result = run(test, iss: example_client_url, appointment_book_selected_response_types:) + + expect(result.result).to eq('wait') + + body['prefetch'] = { 'coverage' => crd_coverage } + header('Authorization', "Bearer #{token}") + post_json(server_endpoint, body) + + expect(last_response).to be_ok + result = results_repo.find(result.id) + expect(result.result).to eq('pass') + end + + it 'returns cards and systemActions and uses client information to build request' do + allow(test).to receive(:suite).and_return(suite) + + token = jwt_helper.build( + aud: appointment_book_url, + iss: example_client_url, + jku: "#{example_client_url}/jwks.json", + encryption_method: 'RS384' + ) + + run(test, iss: example_client_url, appointment_book_selected_response_types:) + + body['prefetch'] = { 'coverage' => crd_coverage } + header('Authorization', "Bearer #{token}") + post_json(server_endpoint, body) + + expect(last_response).to be_ok + + card_response = JSON.parse(last_response.body) + cards = card_response['cards'] + system_actions = card_response['systemActions'] + + expect(cards.length).to eq(2) + expect(system_actions.length).to eq(2) + expect(system_actions.first['resource']['id']).to eq('apt1') + expect(system_actions.last['resource']['id']).to eq('apt2') + + appointment_extension = system_actions.first['resource']['extension'] + coverage_extension = appointment_extension.first['extension'].first + + expect(coverage_extension['url']).to eq('coverage') + expect(coverage_extension['valueReference']['reference']).to eq("Coverage/#{crd_coverage['id']}") + end + + it 'queries the client\'s FHIR server if coverage is not present in the prefetch' do + coverage_search_request = stub_request(:get, + "#{client_fhir_server}/Coverage?patient=#{patient_id}&status=active") + .with( + headers: { 'Authorization' => 'Bearer SAMPLE_TOKEN' } + ) + .to_return(status: 200, body: crd_coverage_bundle.to_json) + + allow(test).to receive(:suite).and_return(suite) + + token = jwt_helper.build( + aud: appointment_book_url, + iss: example_client_url, + jku: "#{example_client_url}/jwks.json", + encryption_method: 'RS384' + ) + + run(test, iss: example_client_url, appointment_book_selected_response_types:) + + header('Authorization', "Bearer #{token}") + post_json(server_endpoint, body) + + expect(last_response).to be_ok + + card_response = JSON.parse(last_response.body) + cards = card_response['cards'] + system_actions = card_response['systemActions'] + + expect(cards.length).to eq(2) + expect(system_actions.length).to eq(2) + expect(system_actions.first['resource']['id']).to eq('apt1') + expect(system_actions.last['resource']['id']).to eq('apt2') + + appointment_extension = system_actions.first['resource']['extension'] + coverage_extension = appointment_extension.first['extension'].first + + expect(coverage_extension['url']).to eq('coverage') + expect(coverage_extension['valueReference']['reference']).to eq("Coverage/#{crd_coverage['id']}") + expect(coverage_search_request).to have_been_made + end + + it 'waits and responds with 500 if request sent to the provided URL and jwt `iss` claim mismatches the given `iss`' do + allow(test).to receive(:suite).and_return(suite) + + token = jwt_helper.build( + aud: appointment_book_url, + iss: example_client_url, + jku: "#{example_client_url}/jwks.json", + encryption_method: 'RS384' + ) + + result = run(test, iss: 'example.com', appointment_book_selected_response_types:) + + expect(result.result).to eq('wait') + + body['prefetch'] = { 'coverage' => crd_coverage } + header('Authorization', "Bearer #{token}") + post_json(server_endpoint, body.to_json) + + expect(last_response).to be_server_error + expect(last_response.body).to match(/find test run with identifier/) + result = results_repo.find(result.id) + expect(result.result).to eq('wait') + end + + it 'waits and responds with 500 if request sent to the provided URL contains the wrong hook name' do + allow(test).to receive(:suite).and_return(suite) + + token = jwt_helper.build( + aud: appointment_book_url, + iss: example_client_url, + jku: "#{example_client_url}/jwks.json", + encryption_method: 'RS384' + ) + + result = run(test, iss: 'example.com', appointment_book_selected_response_types:) + + expect(result.result).to eq('wait') + + body['prefetch'] = { 'coverage' => crd_coverage } + body['hook'] = 'incorrect-hook' + header('Authorization', "Bearer #{token}") + post_json(server_endpoint, body.to_json) + + expect(last_response).to be_server_error + expect(last_response.body).to match(/find test run with identifier/) + result = results_repo.find(result.id) + expect(result.result).to eq('wait') + end + + it 'returns default cards when no appointment_book_selected_response_types selected' do + allow(test).to receive(:suite).and_return(suite) + + token = jwt_helper.build( + aud: appointment_book_url, + iss: example_client_url, + jku: "#{example_client_url}/jwks.json", + encryption_method: 'RS384' + ) + + run(test, iss: example_client_url, appointment_book_selected_response_types: []) + + body['prefetch'] = { 'coverage' => crd_coverage } + header('Authorization', "Bearer #{token}") + post_json(server_endpoint, body) + + expect(last_response).to be_ok + + card_response = JSON.parse(last_response.body) + cards = card_response['cards'] + system_actions = card_response['systemActions'] + + expect(cards.length).to eq(0) + expect(system_actions.length).to eq(2) + expect(system_actions.first['resource']['id']).to eq('apt1') + expect(system_actions.last['resource']['id']).to eq('apt2') + + appointment_extension = system_actions.first['resource']['extension'] + coverage_extension = appointment_extension.first['extension'].first + + expect(coverage_extension['url']).to eq('coverage') + expect(coverage_extension['valueReference']['reference']).to eq("Coverage/#{crd_coverage['id']}") + end + + it 'successfully returns all supported cards when all selected_response_type options are selected' do + allow(test).to receive(:suite).and_return(suite) + + token = jwt_helper.build( + aud: appointment_book_url, + iss: example_client_url, + jku: "#{example_client_url}/jwks.json", + encryption_method: 'RS384' + ) + + run(test, iss: example_client_url, + appointment_book_selected_response_types: appointment_book_selected_response_types + + ['request_form_completion', 'create_update_coverage_info', 'launch_smart_app']) + + body['prefetch'] = { 'coverage' => crd_coverage } + header('Authorization', "Bearer #{token}") + post_json(server_endpoint, body) + + expect(last_response).to be_ok + + card_response = JSON.parse(last_response.body) + cards = card_response['cards'] + system_actions = card_response['systemActions'] + + expect(cards.length).to eq(5) + expect(system_actions.length).to eq(2) + expect(system_actions.first['resource']['id']).to eq('apt1') + expect(system_actions.last['resource']['id']).to eq('apt2') + + expect(cards.first['summary']).to eq('Appointment Book Request Form Completion Card') + expect(cards[1]['summary']).to eq('Appointment Book Launch SMART Application Card') + expect(cards[2]['summary']).to eq('Appointment Book External Reference Card') + expect(cards[3]['summary']).to eq('Appointment Book Create/Update Coverage Information Card') + expect(cards[4]['summary']).to eq('Appointment Book Instructions Card') + + appointment_extension = system_actions.first['resource']['extension'] + coverage_extension = appointment_extension.first['extension'].first + + expect(coverage_extension['url']).to eq('coverage') + expect(coverage_extension['valueReference']['reference']).to eq("Coverage/#{crd_coverage['id']}") + end +end diff --git a/spec/davinci_crd_test_kit/card_optional_fields_validation_test_spec.rb b/spec/davinci_crd_test_kit/card_optional_fields_validation_test_spec.rb new file mode 100644 index 0000000..1343566 --- /dev/null +++ b/spec/davinci_crd_test_kit/card_optional_fields_validation_test_spec.rb @@ -0,0 +1,311 @@ +RSpec.describe DaVinciCRDTestKit::CardOptionalFieldsValidationTest do + let(:runnable) { Inferno::Repositories::Tests.new.find('crd_card_optional_fields_validation') } + let(:session_data_repo) { Inferno::Repositories::SessionData.new } + let(:results_repo) { Inferno::Repositories::Results.new } + let(:test_session) { repo_create(:test_session, test_suite_id: 'crd_server') } + let(:valid_cards) do + json = File.read(File.join(__dir__, '..', 'fixtures', 'valid_cards.json')) + JSON.parse(json) + end + let(:link_required_fields) { ['label', 'type', 'url'] } + let(:override_reasons_required_fields) { ['code', 'system', 'display'] } + let(:suggestions_required_fields) { ['label'] } + let(:actions_required_fields) { ['type', 'description'] } + + def run(runnable, inputs = {}) + test_run_params = { test_session_id: test_session.id }.merge(runnable.reference_hash) + test_run = Inferno::Repositories::TestRuns.new.create(test_run_params) + inputs.each do |name, value| + session_data_repo.save( + test_session_id: test_session.id, + name:, + value:, + type: runnable.config.input_type(name) + ) + end + Inferno::TestRunner.new(test_session:, test_run:).run(runnable) + end + + def entity_result_messages + results_repo.current_results_for_test_session_and_runnables(test_session.id, [runnable]) + .first + .messages + end + + it 'passes if all provided optional fields have the correct type' do + result = run(runnable, valid_cards: valid_cards.to_json) + expect(result.result).to eq('pass') + end + + it 'skips if valid_cards not present' do + result = run(runnable) + expect(result.result).to eq('skip') + expect(result.result_message).to match(/'valid_cards' is nil, skipping test/) + end + + it 'fails if valid_cards a valid json' do + result = run(runnable, valid_cards:) + expect(result.result).to eq('fail') + expect(result.result_message).to match(/Invalid JSON/) + end + + it 'fails if an optional field is not of the correct type' do + valid_cards.first['uuid'] = 2 + result = run(runnable, valid_cards: valid_cards.to_json) + expect(result.result).to eq('fail') + + msg = entity_result_messages.find { |m| m.type == 'error' } + expect(msg.message).to match(/`uuid` is not of type/) + end + + it 'fails if field is of correcty type but empty' do + valid_cards.first['uuid'] = '' + valid_cards.first['links'] = [] + result = run(runnable, valid_cards: valid_cards.to_json) + expect(result.result).to eq('fail') + + msg = entity_result_messages.map(&:message).join(' ') + expect(msg).to match(/`uuid` should not be an empty String/) + expect(msg).to match(/`links` should not be an empty Array/) + end + + it 'fails if a required field is missing from Card.link' do + link_required_fields.each do |field| + dup_cards = valid_cards.deep_dup + card_with_links = dup_cards.find { |card| card['links'].present? } + + card_with_links['links'].first.delete(field) + result = run(runnable, valid_cards: dup_cards.to_json) + expect(result.result).to eq('fail') + + msg = entity_result_messages.find { |m| m.type == 'error' } + expect(msg.message).to match(/does not contain required field `#{field}`/) + end + end + + it 'fails if Card.link required field is a wrong type' do + link_required_fields.each do |field| + dup_cards = valid_cards.deep_dup + card_with_links = dup_cards.find { |card| card['links'].present? } + + card_with_links['links'].first[field] = 123 + result = run(runnable, valid_cards: dup_cards.to_json) + expect(result.result).to eq('fail') + + msg = entity_result_messages.find { |m| m.type == 'error' } + expect(msg.message).to match(/field `#{field}` is not/) + end + end + + it 'fails if Card.link.type is not absolute or smart' do + card_with_links = valid_cards.find { |card| card['links'].present? } + card_with_links['links'].first['type'] = '123' + result = run(runnable, valid_cards: valid_cards.to_json) + expect(result.result).to eq('fail') + msg = entity_result_messages.filter { |m| m.type == 'error' }.map(&:message).join(' ') + expect(msg).to match(/`Link.type` must be `absolute` or `smart`/) + end + + it 'fails if Card.link.appContext is present for absolute link' do + card_with_links = valid_cards.find { |card| card['links'].present? } + card_with_links['links'].first['type'] = 'absolute' + card_with_links['links'].first['appContext'] = 'context' + result = run(runnable, valid_cards: valid_cards.to_json) + expect(result.result).to eq('fail') + msg = entity_result_messages.filter { |m| m.type == 'error' }.map(&:message).join(' ') + expect(msg).to match(/`appContext` field should only be valued if the link type is smart/) + end + + it 'fails if a required field is missing from Card.overrideReasons' do + override_reasons_required_fields.each do |field| + dup_cards = valid_cards.deep_dup + card_with_reasons = dup_cards.find { |card| card['overrideReasons'].present? } + + card_with_reasons['overrideReasons'].first.delete(field) + result = run(runnable, valid_cards: dup_cards.to_json) + expect(result.result).to eq('fail') + + msg = entity_result_messages.find { |m| m.type == 'error' } + expect(msg.message).to match(/does not contain required field `#{field}`/) + end + end + + it 'fails if Card.overrideReasons required field is a wrong type' do + override_reasons_required_fields.each do |field| + dup_cards = valid_cards.deep_dup + card_with_reasons = dup_cards.find { |card| card['overrideReasons'].present? } + + card_with_reasons['overrideReasons'].first[field] = 123 + result = run(runnable, valid_cards: dup_cards.to_json) + expect(result.result).to eq('fail') + + msg = entity_result_messages.find { |m| m.type == 'error' } + expect(msg.message).to match(/field `#{field}` is not/) + end + end + + it 'fails if a required field is missing from Card.suggestions' do + suggestions_required_fields.each do |field| + dup_cards = valid_cards.deep_dup + cards_with_suggestions = dup_cards.find { |card| card['suggestions'].present? } + + cards_with_suggestions['suggestions'].first.delete(field) + result = run(runnable, valid_cards: dup_cards.to_json) + expect(result.result).to eq('fail') + + msg = entity_result_messages.find { |m| m.type == 'error' } + expect(msg.message).to match(/does not contain required field `#{field}`/) + end + end + + it 'fails if Card.suggestions required field is a wrong type' do + suggestions_required_fields.each do |field| + dup_cards = valid_cards.deep_dup + cards_with_suggestions = dup_cards.find { |card| card['suggestions'].present? } + + cards_with_suggestions['suggestions'].first[field] = 123 + result = run(runnable, valid_cards: dup_cards.to_json) + expect(result.result).to eq('fail') + + msg = entity_result_messages.find { |m| m.type == 'error' } + expect(msg.message).to match(/field `#{field}` is not/) + end + end + + it 'fails if a required field is missing from Suggestion action' do + actions_required_fields.each do |field| + dup_cards = valid_cards.deep_dup + cards_with_suggestions = dup_cards.find { |card| card['suggestions'].present? } + actions = cards_with_suggestions['suggestions'].first['actions'] + + actions.first.delete(field) + result = run(runnable, valid_cards: dup_cards.to_json) + expect(result.result).to eq('fail') + + msg = entity_result_messages.find { |m| m.type == 'error' } + expect(msg.message).to match(/does not contain required field `#{field}`/) + end + end + + it 'fails if Suggestion action required field is a wrong type' do + actions_required_fields.each do |field| + dup_cards = valid_cards.deep_dup + cards_with_suggestions = dup_cards.find { |card| card['suggestions'].present? } + actions = cards_with_suggestions['suggestions'].first['actions'] + + actions.first[field] = 123 + result = run(runnable, valid_cards: dup_cards.to_json) + expect(result.result).to eq('fail') + + msg = entity_result_messages.find { |m| m.type == 'error' } + expect(msg.message).to match(/field `#{field}` is not/) + end + end + + it 'fails if `Card.selectionBehavior` is missing when suggestions present' do + cards_with_suggestions = valid_cards.find { |card| card['suggestions'].present? } + cards_with_suggestions.delete('selectionBehavior') + + result = run(runnable, valid_cards: valid_cards.to_json) + expect(result.result).to eq('fail') + + msg = entity_result_messages.find { |m| m.type == 'error' } + expect(msg.message).to match(/`Card.selectionBehavior` must be provided/) + end + + it 'fails if Card.selectionBehavior value is not at-most-one or any' do + cards_with_suggestions = valid_cards.find { |card| card['suggestions'].present? } + cards_with_suggestions['selectionBehavior'] = 'abs' + + result = run(runnable, valid_cards: valid_cards.to_json) + expect(result.result).to eq('fail') + + msg = entity_result_messages.find { |m| m.type == 'error' } + expect(msg.message).to match(/not allowed/) + end + + it 'fails Action.type is not an allowed value' do + cards_with_suggestions = valid_cards.find { |card| card['suggestions'].present? } + actions = cards_with_suggestions['suggestions'].first['actions'] + + actions.first['type'] = 'example' + result = run(runnable, valid_cards: valid_cards.to_json) + expect(result.result).to eq('fail') + + msg = entity_result_messages.find { |m| m.type == 'error' } + expect(msg.message).to match(/is not allowed/) + end + + it 'fails if a create action does not have a resource field' do + cards_with_suggestions = valid_cards.find { |card| card['suggestions'].present? } + create_action = cards_with_suggestions['suggestions'].first['actions'].find { |action| action['type'] == 'create' } + create_action.delete('resource') + + result = run(runnable, valid_cards: valid_cards.to_json) + expect(result.result).to eq('fail') + + msg = entity_result_messages.find { |m| m.type == 'error' } + expect(msg.message).to match(/Action.resource` must be present/) + end + + it 'fails if a create action resource is not a FHIR resource' do + cards_with_suggestions = valid_cards.find { |card| card['suggestions'].present? } + create_action = cards_with_suggestions['suggestions'].first['actions'].find { |action| action['type'] == 'create' } + create_action['resource'] = 'example' + + result = run(runnable, valid_cards: valid_cards.to_json) + expect(result.result).to eq('fail') + + msg = entity_result_messages.find { |m| m.type == 'error' } + expect(msg.message).to match(/`Action.resource` must be a FHIR resource/) + end + + it 'fails if a delete action does not have a resourceId field' do + cards_with_suggestions = valid_cards.find { |card| card['suggestions'].present? } + delete_action = cards_with_suggestions['suggestions'].first['actions'].find { |action| action['type'] == 'delete' } + delete_action.delete('resourceId') + + result = run(runnable, valid_cards: valid_cards.to_json) + expect(result.result).to eq('fail') + + msg = entity_result_messages.find { |m| m.type == 'error' } + expect(msg.message).to match(/does not contain required field/) + end + + it 'fails if a delete action resourceId is not an array' do + cards_with_suggestions = valid_cards.find { |card| card['suggestions'].present? } + delete_action = cards_with_suggestions['suggestions'].first['actions'].find { |action| action['type'] == 'delete' } + delete_action['resourceId'] = 'example' + + result = run(runnable, valid_cards: valid_cards.to_json) + expect(result.result).to eq('fail') + + msg = entity_result_messages.find { |m| m.type == 'error' } + expect(msg.message).to match(/is not of type `Array`/) + end + + it 'fails if a delete action resourceId item is not a relative reference' do + cards_with_suggestions = valid_cards.find { |card| card['suggestions'].present? } + delete_action = cards_with_suggestions['suggestions'].first['actions'].find { |action| action['type'] == 'delete' } + delete_action['resourceId'] = ['example'] + + result = run(runnable, valid_cards: valid_cards.to_json) + expect(result.result).to eq('fail') + + msg = entity_result_messages.find { |m| m.type == 'error' } + expect(msg.message).to match(/Invalid `Action.resourceId item` format/) + end + + it 'persists outputs when valid card with suggestions and/or links are present' do + result = run(runnable, valid_cards: valid_cards.to_json) + expect(result.result).to eq('pass') + + persisted_link_cards = session_data_repo.load(test_session_id: test_session.id, name: :valid_cards_with_links) + persisted_suggestion_cards = session_data_repo.load(test_session_id: test_session.id, + name: :valid_cards_with_suggestions) + cards_with_links = valid_cards.filter { |card| card['links'].present? } + cards_with_suggestions = valid_cards.filter { |card| card['suggestions'].present? } + expect(persisted_link_cards).to eq(cards_with_links.to_json) + expect(persisted_suggestion_cards).to eq(cards_with_suggestions.to_json) + end +end diff --git a/spec/davinci_crd_test_kit/client_fhir_api_create_test_spec.rb b/spec/davinci_crd_test_kit/client_fhir_api_create_test_spec.rb new file mode 100644 index 0000000..724666c --- /dev/null +++ b/spec/davinci_crd_test_kit/client_fhir_api_create_test_spec.rb @@ -0,0 +1,236 @@ +require_relative '../../lib/davinci_crd_test_kit/client_tests/client_fhir_api_create_test' + +RSpec.describe DaVinciCRDTestKit::ClientFHIRApiCreateTest do + let(:suite) { Inferno::Repositories::TestSuites.new.find('crd_client') } + let(:session_data_repo) { Inferno::Repositories::SessionData.new } + let(:test_session) { repo_create(:test_session, test_suite_id: 'crd_client') } + + let(:server_endpoint) { 'http://example.com/fhir' } + let(:client_smart_credentials) do + { + access_token: 'SAMPLE_TOKEN', + refresh_token: 'REFRESH_TOKEN', + expires_in: 3600, + client_id: 'CLIENT_ID', + token_retrieval_time: Time.now.iso8601, + token_url: 'http://example.com/token' + }.to_json + end + + let(:task) do + JSON.parse( + File.read(File.join( + __dir__, '..', 'fixtures', 'crd_task_example.json' + )) + ) + end + + let(:task_second) do + task = JSON.parse( + File.read(File.join( + __dir__, '..', 'fixtures', 'crd_task_example.json' + )) + ) + task['id'] = 'questionnaire-example2' + task + end + + let(:task_id) { 'questionnaire-example' } + let(:task_id_second) { 'questionnaire-example2' } + + let(:patient) do + JSON.parse( + File.read(File.join( + __dir__, '..', 'fixtures', 'crd_patient_example.json' + )) + ) + end + + let(:operation_outcome_success) do + { + outcomes: [{ + issues: [] + }], + sessionId: 'b8cf5547-1dc7-4714-a797-dc2347b93fe2' + } + end + + let(:operation_outcome_failure) do + { + outcomes: [{ + issues: [{ + level: 'ERROR' + }] + }], + sessionId: 'b8cf5547-1dc7-4714-a797-dc2347b93fe2' + } + end + + let(:validator_url) { ENV.fetch('CRD_FHIR_RESOURCE_VALIDATOR_URL') } + + def run(runnable, inputs = {}) + test_run_params = { test_session_id: test_session.id }.merge(runnable.reference_hash) + test_run = Inferno::Repositories::TestRuns.new.create(test_run_params) + inputs.each do |name, value| + session_data_repo.save( + test_session_id: test_session.id, + name:, + value:, + type: runnable.config.input_type(name) + ) + end + Inferno::TestRunner.new(test_session:, test_run:).run(runnable) + end + + describe 'Task FHIR Create Test' do + let(:test) do + Class.new(DaVinciCRDTestKit::ClientFHIRApiCreateTest) do + fhir_client do + url :server_endpoint + oauth_credentials :client_smart_credentials + end + + fhir_resource_validator do + url ENV.fetch('CRD_FHIR_RESOURCE_VALIDATOR_URL', 'http://hl7_validator_service:3500') + + cli_context do + txServer nil + displayWarnings true + disableDefaultResourceFetcher true + end + + igs('hl7.fhir.us.davinci-crd', 'hl7.fhir.us.core') + end + + config( + options: { resource_type: 'Task' } + ) + + input :server_endpoint + input :client_smart_credentials, type: :oauth_credentials + end + end + + it 'passes if valid Task resource is passed in' do + validation_request = stub_request(:post, "#{validator_url}/validate") + .to_return(status: 200, body: operation_outcome_success.to_json) + task_create_request = stub_request(:post, "#{server_endpoint}/Task") + .with( + headers: { Authorization: 'Bearer SAMPLE_TOKEN' } + ) + .to_return(status: 201, body: task.to_json) + result = run(test, create_resources: [task].to_json, server_endpoint:, client_smart_credentials:) + expect(result.result).to eq('pass') + expect(validation_request).to have_been_made + expect(task_create_request).to have_been_made + end + + it 'passes if multiple valid Task resources are passed in' do + validation_request = stub_request(:post, "#{validator_url}/validate") + .to_return(status: 200, body: operation_outcome_success.to_json) + task_create_request = stub_request(:post, "#{server_endpoint}/Task") + .with( + headers: { Authorization: 'Bearer SAMPLE_TOKEN' } + ) + .to_return(status: 201, body: task.to_json).then + .to_return(status: 201, body: task_second.to_json) + + result = run(test, create_resources: [task, task_second].to_json, server_endpoint:, client_smart_credentials:) + expect(result.result).to eq('pass') + expect(validation_request).to have_been_made.times(2) + expect(task_create_request).to have_been_made.times(2) + end + + it 'fails if multiple valid Task resources are passed in and at least 1 returns a non 201' do + validation_request = stub_request(:post, "#{validator_url}/validate") + .to_return(status: 200, body: operation_outcome_success.to_json) + task_create_request = stub_request(:post, "#{server_endpoint}/Task") + .with( + headers: { Authorization: 'Bearer SAMPLE_TOKEN' } + ) + .to_return(status: 201, body: task.to_json).then + .to_return(status: 400, body: task_second.to_json) + + result = run(test, create_resources: [task, task_second].to_json, server_endpoint:, client_smart_credentials:) + expect(result.result).to eq('fail') + expect(result.result_message).to eq('Unexpected response status: expected 201, but received 400') + expect(validation_request).to have_been_made.times(2) + expect(task_create_request).to have_been_made.times(2) + end + + it 'passes if multiple Task resources are passed in and at least 1 is valid' do + validation_request = stub_request(:post, "#{validator_url}/validate") + .to_return(status: 200, body: operation_outcome_success.to_json).then + .to_return(status: 200, body: operation_outcome_failure.to_json).then + .to_return(status: 200, body: operation_outcome_success.to_json).then + task_create_request = stub_request(:post, "#{server_endpoint}/Task") + .with( + headers: { Authorization: 'Bearer SAMPLE_TOKEN' } + ) + .to_return(status: 201, body: task.to_json) + + result = run(test, create_resources: [task, task_second].to_json, server_endpoint:, client_smart_credentials:) + expect(result.result).to eq('pass') + expect(validation_request).to have_been_made.times(2) + expect(task_create_request).to have_been_made + end + + it 'skips if create_resources input is empty' do + result = run(test, create_resources: [], server_endpoint:, client_smart_credentials:) + expect(result.result).to eq('skip') + expect(result.result_message).to eq( + "Input 'create_resources' is nil, skipping test." + ) + end + + it 'skips if empty resource json is inputted' do + result = run(test, create_resources: [{}].to_json, server_endpoint:, client_smart_credentials:) + expect(result.result).to eq('skip') + expect(result.result_message).to eq( + 'No valid Task resources were provided to send in Create requests, skipping test.' + ) + end + + it 'skips if inputted resource is the wrong resource type' do + result = run(test, create_resources: [patient].to_json, server_endpoint:, client_smart_credentials:) + expect(result.result).to eq('skip') + expect(result.result_message).to eq( + 'No valid Task resources were provided to send in Create requests, skipping test.' + ) + end + + it 'skips if passed in Task resource is invalid' do + validation_request = stub_request(:post, "#{validator_url}/validate") + .to_return(status: 200, body: operation_outcome_failure.to_json) + + result = run(test, create_resources: [task].to_json, server_endpoint:, client_smart_credentials:) + expect(result.result).to eq('skip') + expect(result.result_message).to eq( + 'No valid Task resources were provided to send in Create requests, skipping test.' + ) + expect(validation_request).to have_been_made + end + + it 'fails if resource in invalid JSON format is inputted' do + result = run(test, create_resources: '[[', server_endpoint:, client_smart_credentials:) + expect(result.result).to eq('fail') + expect(result.result_message).to eq('Invalid JSON. ') + end + + it 'fails if Task creation interaction returns non 201' do + validation_request = stub_request(:post, "#{validator_url}/validate") + .to_return(status: 200, body: operation_outcome_success.to_json) + task_create_request = stub_request(:post, "#{server_endpoint}/Task") + .with( + headers: { Authorization: 'Bearer SAMPLE_TOKEN' } + ) + .to_return(status: 400) + + result = run(test, create_resources: [task].to_json, server_endpoint:, client_smart_credentials:) + expect(result.result).to eq('fail') + expect(result.result_message).to eq('Unexpected response status: expected 201, but received 400') + expect(validation_request).to have_been_made + expect(task_create_request).to have_been_made + end + end +end diff --git a/spec/davinci_crd_test_kit/client_fhir_api_read_test_spec.rb b/spec/davinci_crd_test_kit/client_fhir_api_read_test_spec.rb new file mode 100644 index 0000000..f201bd8 --- /dev/null +++ b/spec/davinci_crd_test_kit/client_fhir_api_read_test_spec.rb @@ -0,0 +1,179 @@ +require_relative '../../lib/davinci_crd_test_kit/client_tests/client_fhir_api_read_test' + +RSpec.describe DaVinciCRDTestKit::ClientFHIRApiReadTest do + let(:suite) { Inferno::Repositories::TestSuites.new.find('crd_client') } + let(:session_data_repo) { Inferno::Repositories::SessionData.new } + let(:test_session) { repo_create(:test_session, test_suite_id: 'crd_client') } + + let(:server_endpoint) { 'http://example.com/fhir' } + let(:client_smart_credentials) do + { + access_token: 'SAMPLE_TOKEN', + refresh_token: 'REFRESH_TOKEN', + expires_in: 3600, + client_id: 'CLIENT_ID', + token_retrieval_time: Time.now.iso8601, + token_url: 'http://example.com/token' + }.to_json + end + + let(:patient_ids) { 'example' } + + let(:crd_patient_first) do + JSON.parse( + File.read(File.join( + __dir__, '..', 'fixtures', 'crd_patient_example.json' + )) + ) + end + + let(:crd_patient_second) do + patient = JSON.parse( + File.read(File.join( + __dir__, '..', 'fixtures', 'crd_patient_example.json' + )) + ) + patient['id'] = 'example2' + patient + end + + let(:crd_patient_no_id) do + crd_patient_first.except('id') + end + + let(:crd_practitioner) do + JSON.parse( + File.read(File.join( + __dir__, '..', 'fixtures', 'crd_practitioner_example.json' + )) + ) + end + + def run(runnable, inputs = {}) + test_run_params = { test_session_id: test_session.id }.merge(runnable.reference_hash) + test_run = Inferno::Repositories::TestRuns.new.create(test_run_params) + inputs.each do |name, value| + session_data_repo.save( + test_session_id: test_session.id, + name:, + value:, + type: runnable.config.input_type(name) + ) + end + Inferno::TestRunner.new(test_session:, test_run:).run(runnable) + end + + describe 'Patient FHIR Read Test' do + let(:test) do + Class.new(DaVinciCRDTestKit::ClientFHIRApiReadTest) do + fhir_client do + url :server_endpoint + oauth_credentials :client_smart_credentials + end + + config( + options: { resource_type: 'Patient' } + ) + + input :server_endpoint, :resource_ids + input :client_smart_credentials, type: :oauth_credentials + end + end + + it 'passes if valid list of readable Patient ids are passed in' do + patient_resource_request_first = stub_request(:get, "#{server_endpoint}/Patient/example") + .with( + headers: { Authorization: 'Bearer SAMPLE_TOKEN' } + ) + .to_return(status: 200, body: crd_patient_first.to_json) + + result = run(test, resource_ids: patient_ids, server_endpoint:, client_smart_credentials:) + + expect(result.result).to eq('pass') + expect(patient_resource_request_first).to have_been_made + end + + it 'passes if valid list of more than 1 readable Patient id is passed in' do + patient_resource_request_first = stub_request(:get, "#{server_endpoint}/Patient/example") + .with( + headers: { Authorization: 'Bearer SAMPLE_TOKEN' } + ) + .to_return(status: 200, body: crd_patient_first.to_json) + patient_resource_request_second = stub_request(:get, "#{server_endpoint}/Patient/example2") + .with( + headers: { Authorization: 'Bearer SAMPLE_TOKEN' } + ) + .to_return(status: 200, body: crd_patient_second.to_json) + + patient_ids = 'example, example2' + result = run(test, resource_ids: patient_ids, server_endpoint:, client_smart_credentials:) + + expect(result.result).to eq('pass') + expect(patient_resource_request_first).to have_been_made + expect(patient_resource_request_second).to have_been_made + end + + it 'skips if no Patient ids are inputted' do + result = run(test, resource_ids: '', server_endpoint:, client_smart_credentials:) + + expect(result.result).to eq('skip') + end + + it 'fails if Patient id read returns non 200' do + patient_resource_request_first = stub_request(:get, "#{server_endpoint}/Patient/example") + .with( + headers: { Authorization: 'Bearer SAMPLE_TOKEN' } + ) + .to_return(status: 400, body: crd_patient_first.to_json) + + result = run(test, resource_ids: patient_ids, server_endpoint:, client_smart_credentials:) + + expect(result.result).to eq('fail') + expect(result.result_message).to eq('Unexpected response status: expected 200, but received 400') + expect(patient_resource_request_first).to have_been_made + end + + it 'fails if Patient id read returns Patient with wrong id' do + patient_resource_request_first = stub_request(:get, "#{server_endpoint}/Patient/wrong-id") + .with( + headers: { Authorization: 'Bearer SAMPLE_TOKEN' } + ) + .to_return(status: 200, body: crd_patient_first.to_json) + + patient_ids = 'wrong-id' + result = run(test, resource_ids: patient_ids, server_endpoint:, client_smart_credentials:) + + expect(result.result).to eq('fail') + expect(result.result_message).to eq('Expected resource to have id: `wrong-id`, but found `example`') + expect(patient_resource_request_first).to have_been_made + end + + it 'fails if Patient id read returns Patient with no id' do + patient_resource_request_first = stub_request(:get, "#{server_endpoint}/Patient/example") + .with( + headers: { Authorization: 'Bearer SAMPLE_TOKEN' } + ) + .to_return(status: 200, body: crd_patient_no_id.to_json) + + result = run(test, resource_ids: patient_ids, server_endpoint:, client_smart_credentials:) + + expect(result.result).to eq('fail') + expect(result.result_message).to eq('Expected resource to have id: `example`, but found ``') + expect(patient_resource_request_first).to have_been_made + end + + it 'fails if Patient id read returns wrong resource type' do + patient_resource_request_first = stub_request(:get, "#{server_endpoint}/Patient/example") + .with( + headers: { Authorization: 'Bearer SAMPLE_TOKEN' } + ) + .to_return(status: 200, body: crd_practitioner.to_json) + + result = run(test, resource_ids: patient_ids, server_endpoint:, client_smart_credentials:) + + expect(result.result).to eq('fail') + expect(result.result_message).to eq('Unexpected resource type: expected Patient, but received Practitioner') + expect(patient_resource_request_first).to have_been_made + end + end +end diff --git a/spec/davinci_crd_test_kit/client_fhir_api_search_test_spec.rb b/spec/davinci_crd_test_kit/client_fhir_api_search_test_spec.rb new file mode 100644 index 0000000..ada8a11 --- /dev/null +++ b/spec/davinci_crd_test_kit/client_fhir_api_search_test_spec.rb @@ -0,0 +1,853 @@ +require_relative '../../lib/davinci_crd_test_kit/client_tests/client_fhir_api_search_test' + +RSpec.describe DaVinciCRDTestKit::ClientFHIRApiSearchTest do + let(:suite) { Inferno::Repositories::TestSuites.new.find('crd_client') } + let(:session_data_repo) { Inferno::Repositories::SessionData.new } + let(:test_session) { repo_create(:test_session, test_suite_id: 'crd_client') } + + let(:server_endpoint) { 'http://example.com/fhir' } + let(:ehr_smart_credentials) do + { + access_token: 'SAMPLE_TOKEN', + refresh_token: 'REFRESH_TOKEN', + expires_in: 3600, + client_id: 'CLIENT_ID', + token_retrieval_time: Time.now.iso8601, + token_url: 'http://example.com/token' + }.to_json + end + + let(:patient_id) { 'example' } + let(:encounter_id) { 'example' } + let(:encounter_include_search_request) do + "#{server_endpoint}/Encounter?_id=#{encounter_id}&_include=Encounter:location" + end + let(:encounter_include_search_request_different_id) do + "#{server_endpoint}/Encounter?_id=example2&_include=Encounter:location" + end + + let(:crd_coverage_active) do + JSON.parse( + File.read(File.join( + __dir__, '..', 'fixtures', 'crd_coverage_example.json' + )) + ) + end + + let(:crd_coverage_cancelled) do + crd_coverage_active.merge('status' => 'cancelled') + end + + let(:crd_coverage_draft) do + crd_coverage_active.merge('status' => 'draft') + end + + let(:crd_coverage_entered_in_error) do + crd_coverage_active.merge('status' => 'entered-in-error') + end + + let(:crd_encounter) do + JSON.parse( + File.read(File.join( + __dir__, '..', 'fixtures', 'crd_encounter_example.json' + )) + ) + end + + let(:crd_encounter_second) do + crd_encounter_second = crd_encounter.dup + crd_encounter_second['id'] = 'example2' + crd_encounter_second + end + + let(:crd_location) do + JSON.parse( + File.read(File.join( + __dir__, '..', 'fixtures', 'crd_location_example.json' + )) + ) + end + + let(:operation_outcome) do + FHIR::OperationOutcome.new( + issue: [ + { + severity: 'information', + code: 'informational', + details: { + text: 'All OK' + } + } + ] + ) + end + + let(:crd_coverage_search_bundle_active) do + bundle = FHIR::Bundle.new(type: 'searchset') + bundle.entry.append(FHIR::Bundle::Entry.new( + fullUrl: "#{server_endpoint}/Coverage/coverage_example", + resource: FHIR.from_contents(crd_coverage_active.to_json) + )) + bundle + end + + let(:crd_coverage_search_bundle_with_operation_outcome) do + bundle = FHIR::Bundle.new(type: 'searchset') + bundle.entry.append(FHIR::Bundle::Entry.new( + fullUrl: "#{server_endpoint}/Coverage/coverage_example", + resource: FHIR.from_contents(crd_coverage_active.to_json) + ), + FHIR::Bundle::Entry.new( + fullUrl: "#{server_endpoint}/OperationOutcome/operation_outcome_example", + resource: FHIR.from_contents(operation_outcome.to_json) + )) + bundle + end + + let(:crd_coverage_search_bundle_cancelled) do + bundle = FHIR::Bundle.new(type: 'searchset') + bundle.entry.append(FHIR::Bundle::Entry.new( + fullUrl: "#{server_endpoint}/Coverage/coverage_example", + resource: FHIR.from_contents(crd_coverage_cancelled.to_json) + )) + bundle + end + + let(:crd_coverage_search_bundle_draft) do + bundle = FHIR::Bundle.new(type: 'searchset') + bundle.entry.append(FHIR::Bundle::Entry.new( + fullUrl: "#{server_endpoint}/Coverage/coverage_example", + resource: FHIR.from_contents(crd_coverage_draft.to_json) + )) + bundle + end + + let(:crd_coverage_search_bundle_entered_in_error) do + bundle = FHIR::Bundle.new(type: 'searchset') + bundle.entry.append(FHIR::Bundle::Entry.new( + fullUrl: "#{server_endpoint}/Coverage/coverage_example", + resource: FHIR.from_contents(crd_coverage_entered_in_error.to_json) + )) + bundle + end + + let(:crd_encounter_search_bundle) do + bundle = FHIR::Bundle.new(type: 'searchset') + bundle.entry.append(FHIR::Bundle::Entry.new( + fullUrl: "#{server_endpoint}/Encounter/encounter_example", + resource: FHIR.from_contents(crd_encounter.to_json) + )) + bundle + end + + let(:crd_location_search_bundle) do + bundle = FHIR::Bundle.new(type: 'searchset') + bundle.entry.append(FHIR::Bundle::Entry.new( + fullUrl: "#{server_endpoint}/Location/location_example", + resource: FHIR.from_contents(crd_location.to_json) + )) + bundle + end + + let(:crd_encounter_search_bundle_multiple_entries) do + bundle = FHIR::Bundle.new(type: 'searchset') + bundle.entry.append(FHIR::Bundle::Entry.new( + fullUrl: "#{server_endpoint}/Encounter/encounter_example", + resource: FHIR.from_contents(crd_encounter.to_json) + ), FHIR::Bundle::Entry.new( + fullUrl: "#{server_endpoint}/Encounter/encounter_example2", + resource: FHIR.from_contents(crd_encounter_second.to_json) + )) + bundle + end + + let(:crd_encounter_search_bundle_wrong_entries) do + bundle = FHIR::Bundle.new(type: 'searchset') + bundle.entry.append(FHIR::Bundle::Entry.new( + fullUrl: "#{server_endpoint}/Encounter/encounter_example", + resource: FHIR.from_contents(crd_encounter.to_json) + ), FHIR::Bundle::Entry.new( + fullUrl: "#{server_endpoint}/Encounter/encounter_example2", + resource: FHIR.from_contents(crd_coverage_active.to_json) + )) + bundle + end + + let(:crd_encounter_search_bundle_with_location) do + bundle = FHIR::Bundle.new(type: 'searchset') + bundle.entry.append(FHIR::Bundle::Entry.new( + fullUrl: "#{server_endpoint}/Encounter/encounter_example", + resource: FHIR.from_contents(crd_encounter.to_json) + ), FHIR::Bundle::Entry.new( + fullUrl: "#{server_endpoint}/Encounter/encounter_example2", + resource: FHIR.from_contents(crd_location.to_json) + )) + bundle + end + + let(:crd_encounter_search_bundle_with_location_wrong_id) do + crd_location['id'] = 'wrong_id' + bundle = FHIR::Bundle.new(type: 'searchset') + bundle.entry.append(FHIR::Bundle::Entry.new( + fullUrl: "#{server_endpoint}/Encounter/encounter_example", + resource: FHIR.from_contents(crd_encounter.to_json) + ), FHIR::Bundle::Entry.new( + fullUrl: "#{server_endpoint}/Encounter/encounter_example2", + resource: FHIR.from_contents(crd_location.to_json) + )) + bundle + end + + let(:crd_encounter_search_bundle_with_operation_outcome) do + bundle = FHIR::Bundle.new(type: 'searchset') + bundle.entry.append(FHIR::Bundle::Entry.new( + fullUrl: "#{server_endpoint}/Encounter/encounter_example", + resource: FHIR.from_contents(crd_encounter.to_json) + ), + FHIR::Bundle::Entry.new( + fullUrl: "#{server_endpoint}/OperationOutcome/operation_outcome_example", + resource: FHIR.from_contents(operation_outcome.to_json) + )) + bundle + end + + let(:empty_bundle) do + FHIR::Bundle.new(type: 'searchset') + end + + def run(runnable, inputs = {}) + test_run_params = { test_session_id: test_session.id }.merge(runnable.reference_hash) + test_run = Inferno::Repositories::TestRuns.new.create(test_run_params) + inputs.each do |name, value| + session_data_repo.save( + test_session_id: test_session.id, + name: runnable.config.input_name(name), + value:, + type: runnable.config.input_type(name) + ) + end + Inferno::TestRunner.new(test_session:, test_run:).run(runnable) + end + + describe 'Coverage search test with reference search parameter `patient`' do + let(:test) do + Inferno::Repositories::Tests.new.find( + 'crd_client-crd_client_fhir_api-Group02-Group03-crd_client_coverage_patient_search_test' + ) do + fhir_client do + url :url + oauth_credentials :ehr_smart_credentials + end + end + end + + it 'passes if valid Patient id is passed in that can be used to search for Coverage resources' do + coverage_search_request = stub_request(:get, "#{server_endpoint}/Coverage?patient=#{patient_id}") + .with( + headers: { Authorization: 'Bearer SAMPLE_TOKEN' } + ) + .to_return(status: 200, body: crd_coverage_search_bundle_active.to_json) + + result = run(test, search_param_values: patient_id, url: server_endpoint, ehr_smart_credentials:) + + expect(result.result).to eq('pass') + expect(coverage_search_request).to have_been_made + end + + it 'passes if patient search result includes an OperationOutcome resource' do + coverage_search_request = stub_request(:get, "#{server_endpoint}/Coverage?patient=#{patient_id}") + .with( + headers: { Authorization: 'Bearer SAMPLE_TOKEN' } + ) + .to_return(status: 200, body: crd_coverage_search_bundle_with_operation_outcome.to_json) + + result = run(test, search_param_values: patient_id, url: server_endpoint, ehr_smart_credentials:) + + expect(result.result).to eq('pass') + expect(coverage_search_request).to have_been_made + end + + it 'passes if at least 1 of list of Patient ids returns resources in Coverage search' do + coverage_search_request = stub_request(:get, "#{server_endpoint}/Coverage?patient=#{patient_id}") + .with( + headers: { Authorization: 'Bearer SAMPLE_TOKEN' } + ) + .to_return(status: 200, body: crd_coverage_search_bundle_active.to_json) + coverage_search_request_empty = stub_request(:get, "#{server_endpoint}/Coverage?patient=example2") + .with( + headers: { Authorization: 'Bearer SAMPLE_TOKEN' } + ) + .to_return(status: 200, body: empty_bundle.to_json) + + patient_id_list = "#{patient_id}, example2" + result = run(test, search_param_values: patient_id_list, url: server_endpoint, ehr_smart_credentials:) + + expect(result.result).to eq('pass') + expect(coverage_search_request).to have_been_made + expect(coverage_search_request_empty).to have_been_made + end + + it 'skips if no resources returned in Coverage search' do + coverage_search_request_empty = stub_request(:get, "#{server_endpoint}/Coverage?patient=#{patient_id}") + .with( + headers: { Authorization: 'Bearer SAMPLE_TOKEN' } + ) + .to_return(status: 200, body: empty_bundle.to_json) + coverage_search_request_empty_second = stub_request(:get, "#{server_endpoint}/Coverage?patient=example2") + .with( + headers: { Authorization: 'Bearer SAMPLE_TOKEN' } + ) + .to_return(status: 200, body: empty_bundle.to_json) + + patient_id_list = "#{patient_id}, example2" + result = run(test, search_param_values: patient_id_list, url: server_endpoint, ehr_smart_credentials:) + + expect(result.result).to eq('skip') + expect(result.result_message).to eq('No resources returned in any of the search result bundles.') + expect(coverage_search_request_empty).to have_been_made + expect(coverage_search_request_empty_second).to have_been_made + end + + it 'skips if no Patient ids are inputted' do + result = run(test, search_param_values: '', url: server_endpoint, ehr_smart_credentials:) + + expect(result.result).to eq('skip') + expect(result.result_message).to eq('No search parameters passed in, skipping test.') + end + + it 'fails if patient Coverage search returns non 200' do + coverage_search_request = stub_request(:get, "#{server_endpoint}/Coverage?patient=#{patient_id}") + .with( + headers: { Authorization: 'Bearer SAMPLE_TOKEN' } + ) + .to_return(status: 400, body: crd_coverage_search_bundle_active.to_json) + + result = run(test, search_param_values: patient_id, url: server_endpoint, ehr_smart_credentials:) + + expect(result.result).to eq('fail') + expect(result.result_message).to eq('Unexpected response status: expected 200, but received 400') + expect(coverage_search_request).to have_been_made + end + + it 'fails if patient Coverage search returns bundle with non Coverage resources' do + coverage_search_request = stub_request(:get, "#{server_endpoint}/Coverage?patient=#{patient_id}") + .with( + headers: { Authorization: 'Bearer SAMPLE_TOKEN' } + ) + .to_return(status: 200, body: crd_encounter_search_bundle.to_json) + + result = run(test, search_param_values: patient_id, url: server_endpoint, ehr_smart_credentials:) + + expect(result.result).to eq('fail') + expect(result.result_message).to eq('Unexpected resource type: expected Coverage, but received Encounter') + expect(coverage_search_request).to have_been_made + end + + it 'fails if patient Coverage search returns Coverage resource with incorrect beneficiary id' do + coverage_search_request = stub_request(:get, "#{server_endpoint}/Coverage?patient=wrong_id") + .with( + headers: { Authorization: 'Bearer SAMPLE_TOKEN' } + ) + .to_return(status: 200, body: crd_coverage_search_bundle_active.to_json) + + result = run(test, search_param_values: 'wrong_id', url: server_endpoint, ehr_smart_credentials:) + + expect(result.result).to eq('fail') + expect(result.result_message).to match( + 'The Coverage resource in search result bundle with id coverage_example should have a\npatient' + ) + expect(coverage_search_request).to have_been_made + end + end + + describe 'Coverage search test with `status` search parameter' do + let(:test) do + Inferno::Repositories::Tests.new.find( + 'crd_client-crd_client_fhir_api-Group02-Group03-crd_client_coverage_status_search_test' + ) do + fhir_client do + url :url + oauth_credentials :ehr_smart_credentials + end + end + end + + it 'passes if all Coverage status search returns a valid bundle with Coverage resources' do + active_coverage_search_request = stub_request(:get, "#{server_endpoint}/Coverage?status=active") + .with( + headers: { Authorization: 'Bearer SAMPLE_TOKEN' } + ) + .to_return(status: 200, body: crd_coverage_search_bundle_active.to_json) + cancelled_coverage_search_request = stub_request(:get, "#{server_endpoint}/Coverage?status=cancelled") + .with( + headers: { Authorization: 'Bearer SAMPLE_TOKEN' } + ) + .to_return(status: 200, body: crd_coverage_search_bundle_cancelled.to_json) + draft_coverage_search_request = stub_request(:get, "#{server_endpoint}/Coverage?status=draft") + .with( + headers: { Authorization: 'Bearer SAMPLE_TOKEN' } + ) + .to_return(status: 200, body: crd_coverage_search_bundle_draft.to_json) + entered_in_error_coverage_search_request = + stub_request(:get, "#{server_endpoint}/Coverage?status=entered-in-error") + .with( + headers: { Authorization: 'Bearer SAMPLE_TOKEN' } + ) + .to_return(status: 200, body: crd_coverage_search_bundle_entered_in_error.to_json) + + result = run(test, url: server_endpoint, ehr_smart_credentials:) + + expect(result.result).to eq('pass') + expect(active_coverage_search_request).to have_been_made + expect(cancelled_coverage_search_request).to have_been_made + expect(draft_coverage_search_request).to have_been_made + expect(entered_in_error_coverage_search_request).to have_been_made + end + + it 'passes if status search result includes an OperationOutcome resource' do + active_coverage_search_request = stub_request(:get, "#{server_endpoint}/Coverage?status=active") + .with( + headers: { Authorization: 'Bearer SAMPLE_TOKEN' } + ) + .to_return(status: 200, body: crd_coverage_search_bundle_with_operation_outcome.to_json) + cancelled_coverage_search_request = stub_request(:get, "#{server_endpoint}/Coverage?status=cancelled") + .with( + headers: { Authorization: 'Bearer SAMPLE_TOKEN' } + ) + .to_return(status: 200, body: crd_coverage_search_bundle_cancelled.to_json) + draft_coverage_search_request = stub_request(:get, "#{server_endpoint}/Coverage?status=draft") + .with( + headers: { Authorization: 'Bearer SAMPLE_TOKEN' } + ) + .to_return(status: 200, body: crd_coverage_search_bundle_draft.to_json) + entered_in_error_coverage_search_request = + stub_request(:get, "#{server_endpoint}/Coverage?status=entered-in-error") + .with( + headers: { Authorization: 'Bearer SAMPLE_TOKEN' } + ) + .to_return(status: 200, body: crd_coverage_search_bundle_entered_in_error.to_json) + + result = run(test, url: server_endpoint, ehr_smart_credentials:) + + expect(result.result).to eq('pass') + expect(active_coverage_search_request).to have_been_made + expect(cancelled_coverage_search_request).to have_been_made + expect(draft_coverage_search_request).to have_been_made + expect(entered_in_error_coverage_search_request).to have_been_made + end + + it 'passes if at least 1 Coverage status search returns a valid bundle with Coverage resources' do + active_coverage_search_request = stub_request(:get, "#{server_endpoint}/Coverage?status=active") + .with( + headers: { Authorization: 'Bearer SAMPLE_TOKEN' } + ) + .to_return(status: 200, body: empty_bundle.to_json) + cancelled_coverage_search_request = stub_request(:get, "#{server_endpoint}/Coverage?status=cancelled") + .with( + headers: { Authorization: 'Bearer SAMPLE_TOKEN' } + ) + .to_return(status: 200, body: empty_bundle.to_json) + draft_coverage_search_request = stub_request(:get, "#{server_endpoint}/Coverage?status=draft") + .with( + headers: { Authorization: 'Bearer SAMPLE_TOKEN' } + ) + .to_return(status: 200, body: crd_coverage_search_bundle_draft.to_json) + entered_in_error_coverage_search_request = + stub_request(:get, "#{server_endpoint}/Coverage?status=entered-in-error") + .with( + headers: { Authorization: 'Bearer SAMPLE_TOKEN' } + ) + .to_return(status: 200, body: empty_bundle.to_json) + + result = run(test, url: server_endpoint, ehr_smart_credentials:) + + expect(result.result).to eq('pass') + expect(active_coverage_search_request).to have_been_made + expect(cancelled_coverage_search_request).to have_been_made + expect(draft_coverage_search_request).to have_been_made + expect(entered_in_error_coverage_search_request).to have_been_made + end + + it 'skips if all Coverage status search returns empty bundles' do + active_coverage_search_request = stub_request(:get, "#{server_endpoint}/Coverage?status=active") + .with( + headers: { Authorization: 'Bearer SAMPLE_TOKEN' } + ) + .to_return(status: 200, body: empty_bundle.to_json) + cancelled_coverage_search_request = stub_request(:get, "#{server_endpoint}/Coverage?status=cancelled") + .with( + headers: { Authorization: 'Bearer SAMPLE_TOKEN' } + ) + .to_return(status: 200, body: empty_bundle.to_json) + draft_coverage_search_request = stub_request(:get, "#{server_endpoint}/Coverage?status=draft") + .with( + headers: { Authorization: 'Bearer SAMPLE_TOKEN' } + ) + .to_return(status: 200, body: empty_bundle.to_json) + entered_in_error_coverage_search_request = + stub_request(:get, "#{server_endpoint}/Coverage?status=entered-in-error") + .with( + headers: { Authorization: 'Bearer SAMPLE_TOKEN' } + ) + .to_return(status: 200, body: empty_bundle.to_json) + + result = run(test, url: server_endpoint, ehr_smart_credentials:) + expect(result.result).to eq('skip') + expect(result.result_message).to eq('No resources returned in any of the search result bundles.') + expect(active_coverage_search_request).to have_been_made + expect(cancelled_coverage_search_request).to have_been_made + expect(draft_coverage_search_request).to have_been_made + expect(entered_in_error_coverage_search_request).to have_been_made + end + + it 'fails if status Coverage search returns non 200' do + active_coverage_search_request = stub_request(:get, "#{server_endpoint}/Coverage?status=active") + .with( + headers: { Authorization: 'Bearer SAMPLE_TOKEN' } + ) + .to_return(status: 400, body: crd_coverage_search_bundle_active.to_json) + + result = run(test, url: server_endpoint, ehr_smart_credentials:) + + expect(result.result).to eq('fail') + expect(result.result_message).to eq('Unexpected response status: expected 200, but received 400') + expect(active_coverage_search_request).to have_been_made + end + + it 'fails if status Coverage search returns bundle with non Coverage resources' do + active_coverage_search_request = stub_request(:get, "#{server_endpoint}/Coverage?status=active") + .with( + headers: { Authorization: 'Bearer SAMPLE_TOKEN' } + ) + .to_return(status: 200, body: crd_encounter_search_bundle.to_json) + + result = run(test, url: server_endpoint, ehr_smart_credentials:) + + expect(result.result).to eq('fail') + expect(result.result_message).to eq('Unexpected resource type: expected Coverage, but received Encounter') + expect(active_coverage_search_request).to have_been_made + end + + it 'fails if status Coverage search returns bundle with incorrect Coverage status' do + active_coverage_search_request = stub_request(:get, "#{server_endpoint}/Coverage?status=active") + .with( + headers: { Authorization: 'Bearer SAMPLE_TOKEN' } + ) + .to_return(status: 200, body: crd_coverage_search_bundle_cancelled.to_json) + + result = run(test, url: server_endpoint, ehr_smart_credentials:) + + expect(result.result).to eq('fail') + expect(result.result_message).to match( + 'Each Coverage resource in search result bundle should have a status of `active`, instead got' + ) + expect(active_coverage_search_request).to have_been_made + end + end + + describe 'Encounter search test with `_id` search parameter' do + let(:test) do + Inferno::Repositories::Tests.new.find( + 'crd_client-crd_client_fhir_api-Group02-Group06-crd_client_encounter_id_search_test' + ) do + fhir_client do + url :url + oauth_credentials :ehr_smart_credentials + end + end + end + + it 'passes if valid Encounter id is passed in that can be used to search for Encounter resources' do + encounter_search_request = stub_request(:get, "#{server_endpoint}/Encounter?_id=#{encounter_id}") + .with( + headers: { Authorization: 'Bearer SAMPLE_TOKEN' } + ) + .to_return(status: 200, body: crd_encounter_search_bundle.to_json) + + result = run(test, search_param_values: encounter_id, url: server_endpoint, ehr_smart_credentials:) + + expect(result.result).to eq('pass') + expect(encounter_search_request).to have_been_made + end + + it 'passes if _id search result includes an OperationOutcome resource' do + encounter_search_request = stub_request(:get, "#{server_endpoint}/Encounter?_id=#{encounter_id}") + .with( + headers: { Authorization: 'Bearer SAMPLE_TOKEN' } + ) + .to_return(status: 200, body: crd_encounter_search_bundle_with_operation_outcome.to_json) + + result = run(test, search_param_values: encounter_id, url: server_endpoint, ehr_smart_credentials:) + + expect(result.result).to eq('pass') + expect(encounter_search_request).to have_been_made + end + + it 'passes if at least 1 of list of Encounter ids returns resources in Encounter _id search' do + encounter_search_request = stub_request(:get, "#{server_endpoint}/Encounter?_id=#{encounter_id}") + .with( + headers: { Authorization: 'Bearer SAMPLE_TOKEN' } + ) + .to_return(status: 200, body: crd_encounter_search_bundle.to_json) + encounter_search_request_empty = stub_request(:get, "#{server_endpoint}/Encounter?_id=example2") + .with( + headers: { Authorization: 'Bearer SAMPLE_TOKEN' } + ) + .to_return(status: 200, body: empty_bundle.to_json) + + encounter_id_list = "#{encounter_id}, example2" + result = run(test, search_param_values: encounter_id_list, url: server_endpoint, ehr_smart_credentials:) + + expect(result.result).to eq('pass') + expect(encounter_search_request).to have_been_made + expect(encounter_search_request_empty).to have_been_made + end + + it 'skips if no resources returned in Encounter _id search' do + encounter_search_request = stub_request(:get, "#{server_endpoint}/Encounter?_id=#{encounter_id}") + .with( + headers: { Authorization: 'Bearer SAMPLE_TOKEN' } + ) + .to_return(status: 200, body: empty_bundle.to_json) + encounter_search_request_empty = stub_request(:get, "#{server_endpoint}/Encounter?_id=example2") + .with( + headers: { Authorization: 'Bearer SAMPLE_TOKEN' } + ) + .to_return(status: 200, body: empty_bundle.to_json) + + encounter_id_list = "#{encounter_id}, example2" + result = run(test, search_param_values: encounter_id_list, url: server_endpoint, ehr_smart_credentials:) + + expect(result.result).to eq('skip') + expect(result.result_message).to eq('No resources returned in any of the search result bundles.') + expect(encounter_search_request).to have_been_made + expect(encounter_search_request_empty).to have_been_made + end + + it 'skips if no Encounter ids are inputted' do + result = run(test, search_param_values: '', url: server_endpoint, ehr_smart_credentials:) + + expect(result.result).to eq('skip') + expect(result.result_message).to eq('No search parameters passed in, skipping test.') + end + + it 'fails if Encounter _id search returns non 200' do + encounter_search_request = stub_request(:get, "#{server_endpoint}/Encounter?_id=#{encounter_id}") + .with( + headers: { Authorization: 'Bearer SAMPLE_TOKEN' } + ) + .to_return(status: 400, body: crd_encounter_search_bundle.to_json) + + result = run(test, search_param_values: encounter_id, url: server_endpoint, ehr_smart_credentials:) + + expect(result.result).to eq('fail') + expect(result.result_message).to eq('Unexpected response status: expected 200, but received 400') + expect(encounter_search_request).to have_been_made + end + + it 'fails if Encounter _id search returns bundle with non Encounter resource' do + encounter_search_request = stub_request(:get, "#{server_endpoint}/Encounter?_id=#{encounter_id}") + .with( + headers: { Authorization: 'Bearer SAMPLE_TOKEN' } + ) + .to_return(status: 200, body: crd_coverage_search_bundle_active.to_json) + + result = run(test, search_param_values: encounter_id, url: server_endpoint, ehr_smart_credentials:) + + expect(result.result).to eq('fail') + expect(result.result_message).to eq('Unexpected resource type: expected Encounter, but received Coverage') + expect(encounter_search_request).to have_been_made + end + + it 'fails if Encounter _id search returns Encounter resource with wrong id' do + encounter_search_request = stub_request(:get, "#{server_endpoint}/Encounter?_id=wrong_id") + .with( + headers: { Authorization: 'Bearer SAMPLE_TOKEN' } + ) + .to_return(status: 200, body: crd_encounter_search_bundle.to_json) + + result = run(test, search_param_values: 'wrong_id', url: server_endpoint, ehr_smart_credentials:) + + expect(result.result).to eq('fail') + expect(result.result_message).to eq('Expected resource to have id: `wrong_id`, but found `example`') + expect(encounter_search_request).to have_been_made + end + end + + describe 'Encounter search test with `_include` search parameter' do + let(:test) do + Inferno::Repositories::Tests.new.find( + 'crd_client-crd_client_fhir_api-Group02-Group06-crd_client_encounter_location_include_test' + ) do + fhir_client do + url :url + oauth_credentials :ehr_smart_credentials + end + end + end + + it 'passes if valid Encounter id is passed in that can be used to _include search for Encounter resources' do + encounter_search_request = stub_request(:get, encounter_include_search_request) + .with( + headers: { Authorization: 'Bearer SAMPLE_TOKEN' } + ) + .to_return(status: 200, body: crd_encounter_search_bundle_with_location.to_json) + + result = run(test, search_param_values: encounter_id, url: server_endpoint, ehr_smart_credentials:) + + expect(result.result).to eq('pass') + expect(encounter_search_request).to have_been_made + end + + it 'passes if _include search result includes an OperationOutcome resource' do + encounter_search_request = stub_request(:get, encounter_include_search_request) + .with( + headers: { Authorization: 'Bearer SAMPLE_TOKEN' } + ) + .to_return(status: 200, body: crd_encounter_search_bundle_with_operation_outcome.to_json) + + result = run(test, search_param_values: encounter_id, url: server_endpoint, ehr_smart_credentials:) + + expect(result.result).to eq('pass') + expect(encounter_search_request).to have_been_made + end + + it 'passes if at least 1 of list of Encounter ids returns resources in Encounter _include search' do + encounter_search_request = stub_request(:get, encounter_include_search_request) + .with( + headers: { Authorization: 'Bearer SAMPLE_TOKEN' } + ) + .to_return(status: 200, body: crd_encounter_search_bundle_with_location.to_json) + encounter_search_request_empty = stub_request(:get, encounter_include_search_request_different_id) + .with( + headers: { Authorization: 'Bearer SAMPLE_TOKEN' } + ) + .to_return(status: 200, body: empty_bundle.to_json) + + encounter_id_list = "#{encounter_id}, example2" + result = run(test, search_param_values: encounter_id_list, url: server_endpoint, ehr_smart_credentials:) + + expect(result.result).to eq('pass') + expect(encounter_search_request).to have_been_made + expect(encounter_search_request_empty).to have_been_made + end + + it 'skips if no resources returned in Encounter _include search' do + encounter_search_request = stub_request(:get, encounter_include_search_request) + .with( + headers: { Authorization: 'Bearer SAMPLE_TOKEN' } + ) + .to_return(status: 200, body: empty_bundle.to_json) + encounter_search_request_empty = stub_request(:get, encounter_include_search_request_different_id) + .with( + headers: { Authorization: 'Bearer SAMPLE_TOKEN' } + ) + .to_return(status: 200, body: empty_bundle.to_json) + + encounter_id_list = "#{encounter_id}, example2" + result = run(test, search_param_values: encounter_id_list, url: server_endpoint, ehr_smart_credentials:) + + expect(result.result).to eq('skip') + expect(result.result_message).to eq('No resources returned in any of the search result bundles.') + expect(encounter_search_request).to have_been_made + expect(encounter_search_request_empty).to have_been_made + end + + it 'skips if no Encounter ids are inputted' do + result = run(test, search_param_values: '', url: server_endpoint, ehr_smart_credentials:) + + expect(result.result).to eq('skip') + expect(result.result_message).to eq('No search parameters passed in, skipping test.') + end + + it 'fails if Encounter _id search returns non 200' do + encounter_search_request = stub_request(:get, encounter_include_search_request) + .with( + headers: { Authorization: 'Bearer SAMPLE_TOKEN' } + ) + .to_return(status: 400, body: crd_encounter_search_bundle_with_location.to_json) + + result = run(test, search_param_values: encounter_id, url: server_endpoint, ehr_smart_credentials:) + + expect(result.result).to eq('fail') + expect(result.result_message).to eq('Unexpected response status: expected 200, but received 400') + expect(encounter_search_request).to have_been_made + end + + it 'fails if Encounter _include search returns a bundle with no Encounter resource' do + encounter_search_request = stub_request(:get, encounter_include_search_request) + .with( + headers: { Authorization: 'Bearer SAMPLE_TOKEN' } + ) + .to_return(status: 200, body: crd_location_search_bundle.to_json) + + result = run(test, search_param_values: encounter_id, url: server_endpoint, ehr_smart_credentials:) + + expect(result.result).to eq('fail') + expect(result.result_message).to match( + 'should include exactly 1 Encounter resource, instead got 0' + ) + expect(encounter_search_request).to have_been_made + end + + it 'fails if Encounter _include search returns a bundle with more than 1 Encounter resource' do + encounter_search_request = stub_request(:get, encounter_include_search_request) + .with( + headers: { Authorization: 'Bearer SAMPLE_TOKEN' } + ) + .to_return(status: 200, body: crd_encounter_search_bundle_multiple_entries.to_json) + + result = run(test, search_param_values: encounter_id, url: server_endpoint, ehr_smart_credentials:) + + expect(result.result).to eq('fail') + expect(result.result_message).to match( + 'should include exactly 1 Encounter resource, instead got 2' + ) + expect(encounter_search_request).to have_been_made + end + + it 'fails if Encounter _include search returns a bundle with incorrect resource types' do + encounter_search_request = stub_request(:get, encounter_include_search_request) + .with( + headers: { Authorization: 'Bearer SAMPLE_TOKEN' } + ) + .to_return(status: 200, body: crd_encounter_search_bundle_wrong_entries.to_json) + + result = run(test, search_param_values: encounter_id, url: server_endpoint, ehr_smart_credentials:) + + expect(result.result).to eq('fail') + expect(result.result_message).to match('Unexpected resource type: expected Location, but received Coverage') + expect(encounter_search_request).to have_been_made + end + + it 'fails if Encounter _include search returns a bundle with wrong Encounter id' do + encounter_search_request = stub_request(:get, encounter_include_search_request_different_id) + .with( + headers: { Authorization: 'Bearer SAMPLE_TOKEN' } + ) + .to_return(status: 200, body: crd_encounter_search_bundle_with_location.to_json) + + result = run(test, search_param_values: 'example2', url: server_endpoint, ehr_smart_credentials:) + + expect(result.result).to eq('fail') + expect(result.result_message).to match('Expected resource to have id: `example2`, but found `example`') + expect(encounter_search_request).to have_been_made + end + + it 'fails if Encounter _id search returns Location resources that are not referenced by the Encounter resource' do + encounter_search_request = stub_request(:get, encounter_include_search_request) + .with( + headers: { Authorization: 'Bearer SAMPLE_TOKEN' } + ) + .to_return(status: 200, body: crd_encounter_search_bundle_with_location_wrong_id.to_json) + + result = run(test, search_param_values: encounter_id, url: server_endpoint, ehr_smart_credentials:) + + expect(result.result).to eq('fail') + expect(result.result_message).to match( + 'The Encounter resource in search result bundle with id example did not have a\nlocation reference' + ) + expect(encounter_search_request).to have_been_made + end + end +end diff --git a/spec/davinci_crd_test_kit/client_fhir_api_update_test_spec.rb b/spec/davinci_crd_test_kit/client_fhir_api_update_test_spec.rb new file mode 100644 index 0000000..474d7fb --- /dev/null +++ b/spec/davinci_crd_test_kit/client_fhir_api_update_test_spec.rb @@ -0,0 +1,270 @@ +require_relative '../../lib/davinci_crd_test_kit/client_tests/client_fhir_api_update_test' + +RSpec.describe DaVinciCRDTestKit::ClientFHIRApiUpdateTest do + let(:suite) { Inferno::Repositories::TestSuites.new.find('crd_client') } + let(:session_data_repo) { Inferno::Repositories::SessionData.new } + let(:test_session) { repo_create(:test_session, test_suite_id: 'crd_client') } + + let(:server_endpoint) { 'http://example.com/fhir' } + let(:client_smart_credentials) do + { + access_token: 'SAMPLE_TOKEN', + refresh_token: 'REFRESH_TOKEN', + expires_in: 3600, + client_id: 'CLIENT_ID', + token_retrieval_time: Time.now.iso8601, + token_url: 'http://example.com/token' + }.to_json + end + + let(:encounter) do + JSON.parse( + File.read(File.join( + __dir__, '..', 'fixtures', 'crd_encounter_example.json' + )) + ) + end + + let(:encounter_second) do + encounter = JSON.parse( + File.read(File.join( + __dir__, '..', 'fixtures', 'crd_encounter_example.json' + )) + ) + encounter['id'] = 'example2' + encounter + end + + let(:encounter_id) { 'example' } + let(:encounter_id_second) { 'example2' } + + let(:patient) do + JSON.parse( + File.read(File.join( + __dir__, '..', 'fixtures', 'crd_patient_example.json' + )) + ) + end + + let(:operation_outcome_success) do + { + outcomes: [{ + issues: [] + }], + sessionId: 'b8cf5547-1dc7-4714-a797-dc2347b93fe2' + } + end + + let(:operation_outcome_failure) do + { + outcomes: [{ + issues: [{ + level: 'ERROR' + }] + }], + sessionId: 'b8cf5547-1dc7-4714-a797-dc2347b93fe2' + } + end + + let(:validator_url) { ENV.fetch('CRD_FHIR_RESOURCE_VALIDATOR_URL') } + + def run(runnable, inputs = {}) + test_run_params = { test_session_id: test_session.id }.merge(runnable.reference_hash) + test_run = Inferno::Repositories::TestRuns.new.create(test_run_params) + inputs.each do |name, value| + session_data_repo.save( + test_session_id: test_session.id, + name:, + value:, + type: runnable.config.input_type(name) + ) + end + Inferno::TestRunner.new(test_session:, test_run:).run(runnable) + end + + describe 'Encounter FHIR Update Test' do + let(:test) do + Class.new(DaVinciCRDTestKit::ClientFHIRApiUpdateTest) do + fhir_client do + url :server_endpoint + oauth_credentials :client_smart_credentials + end + + fhir_resource_validator do + url ENV.fetch('CRD_FHIR_RESOURCE_VALIDATOR_URL', 'http://hl7_validator_service:3500') + + cli_context do + txServer nil + displayWarnings true + disableDefaultResourceFetcher true + end + + igs('hl7.fhir.us.davinci-crd', 'hl7.fhir.us.core') + end + + config( + options: { resource_type: 'Encounter' } + ) + + input :server_endpoint + input :client_smart_credentials, type: :oauth_credentials + end + end + + it 'passes if valid Encounter resource is passed in' do + validation_request = stub_request(:post, "#{validator_url}/validate") + .to_return(status: 200, body: operation_outcome_success.to_json) + encounter_update_request = stub_request(:put, "#{server_endpoint}/Encounter/#{encounter_id}") + .with( + headers: { Authorization: 'Bearer SAMPLE_TOKEN' } + ) + .to_return(status: 200, body: encounter.to_json) + + result = run(test, update_resources: [encounter].to_json, server_endpoint:, + client_smart_credentials:) + expect(result.result).to eq('pass') + expect(validation_request).to have_been_made + expect(encounter_update_request).to have_been_made + end + + it 'passes if valid Encounter resource is passed in and create interaction returns 201' do + validation_request = stub_request(:post, "#{validator_url}/validate") + .to_return(status: 200, body: operation_outcome_success.to_json) + encounter_update_request = stub_request(:put, "#{server_endpoint}/Encounter/#{encounter_id}") + .with( + headers: { Authorization: 'Bearer SAMPLE_TOKEN' } + ) + .to_return(status: 201, body: encounter.to_json) + + result = run(test, update_resources: [encounter].to_json, server_endpoint:, + client_smart_credentials:) + expect(result.result).to eq('pass') + expect(validation_request).to have_been_made + expect(encounter_update_request).to have_been_made + end + + it 'passes if multiple valid Encounter resource are passed in' do + validation_request = stub_request(:post, "#{validator_url}/validate") + .to_return(status: 200, body: operation_outcome_success.to_json) + encounter_update_request = stub_request(:put, "#{server_endpoint}/Encounter/#{encounter_id}") + .with( + headers: { Authorization: 'Bearer SAMPLE_TOKEN' } + ) + .to_return(status: 200, body: encounter.to_json) + encounter_update_request_second = stub_request(:put, "#{server_endpoint}/Encounter/#{encounter_id_second}") + .with( + headers: { Authorization: 'Bearer SAMPLE_TOKEN' } + ) + .to_return(status: 200, body: encounter_second.to_json) + + result = run(test, update_resources: [encounter, encounter_second].to_json, server_endpoint:, + client_smart_credentials:) + expect(result.result).to eq('pass') + expect(validation_request).to have_been_made.times(2) + expect(encounter_update_request).to have_been_made + expect(encounter_update_request_second).to have_been_made + end + + it 'fails if multiple valid Encounter resource are passed in and at least 1 returns a non 200 or 201' do + validation_request = stub_request(:post, "#{validator_url}/validate") + .to_return(status: 200, body: operation_outcome_success.to_json) + encounter_update_request = stub_request(:put, "#{server_endpoint}/Encounter/#{encounter_id}") + .with( + headers: { Authorization: 'Bearer SAMPLE_TOKEN' } + ) + .to_return(status: 200, body: encounter.to_json) + encounter_update_request_second = stub_request(:put, "#{server_endpoint}/Encounter/#{encounter_id_second}") + .with( + headers: { Authorization: 'Bearer SAMPLE_TOKEN' } + ) + .to_return(status: 400, body: encounter_second.to_json) + + result = run(test, update_resources: [encounter, encounter_second].to_json, server_endpoint:, + client_smart_credentials:) + expect(result.result).to eq('fail') + expect(result.result_message).to eq('Unexpected response status: expected 200, 201, but received 400') + expect(validation_request).to have_been_made.times(2) + expect(encounter_update_request).to have_been_made + expect(encounter_update_request_second).to have_been_made + end + + it 'passes if multiple Encounter resource are passed in and at least 1 is valid' do + validation_request = stub_request(:post, "#{validator_url}/validate") + .to_return(status: 200, body: operation_outcome_success.to_json).then + .to_return(status: 200, body: operation_outcome_failure.to_json).then + .to_return(status: 200, body: operation_outcome_success.to_json).then + encounter_update_request = stub_request(:put, "#{server_endpoint}/Encounter/#{encounter_id}") + .with( + headers: { Authorization: 'Bearer SAMPLE_TOKEN' } + ) + .to_return(status: 200, body: encounter.to_json) + + result = run(test, update_resources: [encounter, encounter_second].to_json, server_endpoint:, + client_smart_credentials:) + expect(result.result).to eq('pass') + expect(validation_request).to have_been_made.times(2) + expect(encounter_update_request).to have_been_made + end + + it 'skips if update_resources input is empty' do + result = run(test, update_resources: [], server_endpoint:, client_smart_credentials:) + expect(result.result).to eq('skip') + expect(result.result_message).to eq( + "Input 'update_resources' is nil, skipping test." + ) + end + + it 'skips if empty resource json is inputted' do + result = run(test, update_resources: [{}], server_endpoint:, client_smart_credentials:) + expect(result.result).to eq('skip') + expect(result.result_message).to eq( + 'No valid Encounter resources were provided to send in Update requests, skipping test.' + ) + end + + it 'skips if inputted resource is the wrong resource type' do + result = run(test, update_resources: [patient].to_json, server_endpoint:, + client_smart_credentials:) + expect(result.result).to eq('skip') + expect(result.result_message).to eq( + 'No valid Encounter resources were provided to send in Update requests, skipping test.' + ) + end + + it 'skips if passed in Encounter resource is invalid' do + validation_request = stub_request(:post, "#{validator_url}/validate") + .to_return(status: 200, body: operation_outcome_failure.to_json) + + result = run(test, update_resources: [encounter].to_json, server_endpoint:, + client_smart_credentials:) + expect(result.result).to eq('skip') + expect(result.result_message).to eq( + 'No valid Encounter resources were provided to send in Update requests, skipping test.' + ) + expect(validation_request).to have_been_made + end + + it 'fails if resource in invalid JSON format is inputted' do + result = run(test, update_resources: '[[', server_endpoint:, client_smart_credentials:) + expect(result.result).to eq('fail') + expect(result.result_message).to eq('Invalid JSON. ') + end + + it 'fails if Encounter update interaction returns non 200 or 201' do + validation_request = stub_request(:post, "#{validator_url}/validate") + .to_return(status: 200, body: operation_outcome_success.to_json) + encounter_update_request = stub_request(:put, "#{server_endpoint}/Encounter/#{encounter_id}") + .with( + headers: { Authorization: 'Bearer SAMPLE_TOKEN' } + ) + .to_return(status: 400) + + result = run(test, update_resources: [encounter].to_json, server_endpoint:, + client_smart_credentials:) + expect(result.result).to eq('fail') + expect(result.result_message).to eq('Unexpected response status: expected 200, 201, but received 400') + expect(validation_request).to have_been_made + expect(encounter_update_request).to have_been_made + end + end +end diff --git a/spec/davinci_crd_test_kit/client_fhir_api_validation_test_spec.rb b/spec/davinci_crd_test_kit/client_fhir_api_validation_test_spec.rb new file mode 100644 index 0000000..d11312b --- /dev/null +++ b/spec/davinci_crd_test_kit/client_fhir_api_validation_test_spec.rb @@ -0,0 +1,280 @@ +require_relative '../../lib/davinci_crd_test_kit/client_tests/client_fhir_api_validation_test' + +RSpec.describe DaVinciCRDTestKit::ClientFHIRApiValidationTest do + let(:suite) { Inferno::Repositories::TestSuites.new.find('crd_client') } + let(:session_data_repo) { Inferno::Repositories::SessionData.new } + let(:test_session) { repo_create(:test_session, test_suite_id: 'crd_client') } + let(:result) { repo_create(:result, test_session_id: test_session.id) } + + let(:server_endpoint) { 'http://example.com/fhir' } + + let(:encounter_id) { 'example' } + let(:organization_id) { 'example' } + + let(:crd_encounter) do + JSON.parse( + File.read(File.join( + __dir__, '..', 'fixtures', 'crd_encounter_example.json' + )) + ) + end + + let(:crd_encounter_second) do + crd_encounter_second = crd_encounter.dup + crd_encounter_second['id'] = 'example2' + crd_encounter_second + end + + let(:crd_location) do + JSON.parse( + File.read(File.join( + __dir__, '..', 'fixtures', 'crd_location_example.json' + )) + ) + end + + let(:crd_encounter_search_bundle) do + bundle = FHIR::Bundle.new(type: 'searchset') + bundle.entry.append(FHIR::Bundle::Entry.new( + fullUrl: "#{server_endpoint}/Encounter/encounter_example", + resource: FHIR.from_contents(crd_encounter.to_json), + id: 'encounter_entry' + )) + bundle + end + + let(:crd_encounter_search_bundle_multiple_entries) do + bundle = FHIR::Bundle.new(type: 'searchset') + bundle.entry.append(FHIR::Bundle::Entry.new( + fullUrl: "#{server_endpoint}/Encounter/encounter_example", + resource: FHIR.from_contents(crd_encounter.to_json) + ), FHIR::Bundle::Entry.new( + fullUrl: "#{server_endpoint}/Encounter/encounter_example", + resource: FHIR.from_contents(crd_encounter_second.to_json) + )) + bundle + end + + let(:crd_encounter_search_bundle_with_location) do + bundle = FHIR::Bundle.new(type: 'searchset') + bundle.entry.append(FHIR::Bundle::Entry.new( + fullUrl: "#{server_endpoint}/Encounter/encounter_example", + resource: FHIR.from_contents(crd_encounter.to_json) + ), FHIR::Bundle::Entry.new( + fullUrl: "#{server_endpoint}/Encounter/encounter_example2", + resource: FHIR.from_contents(crd_location.to_json) + )) + bundle + end + + let(:operation_outcome_success) do + { + outcomes: [{ + issues: [] + }], + sessionId: 'b8cf5547-1dc7-4714-a797-dc2347b93fe2' + } + end + + let(:operation_outcome_failure) do + { + outcomes: [{ + issues: [{ + level: 'ERROR' + }] + }], + sessionId: 'b8cf5547-1dc7-4714-a797-dc2347b93fe2' + } + end + + let(:validator_url) { ENV.fetch('CRD_FHIR_RESOURCE_VALIDATOR_URL') } + + def run(runnable, inputs = {}) + test_run_params = { test_session_id: test_session.id }.merge(runnable.reference_hash) + test_run = Inferno::Repositories::TestRuns.new.create(test_run_params) + inputs.each do |name, value| + session_data_repo.save( + test_session_id: test_session.id, + name:, + value:, + type: runnable.config.input_type(name) + ) + end + Inferno::TestRunner.new(test_session:, test_run:).run(runnable) + end + + def create_fhir_api_requests(url: nil, body: nil, status: 200, search_tag: nil, name: nil) + headers ||= [ + { + type: 'request', + name: 'Authorization', + value: 'Bearer SAMPLE_TOKEN' + } + ] + repo_create( + :request, + direction: 'outgoing', + url:, + name:, + test_session_id: test_session.id, + result:, + response_body: body.is_a?(Hash) ? body.to_json : body, + tags: ['Encounter', search_tag], + status:, + headers: + ) + end + + describe 'FHIR Resource Validation' do + let(:test) do + Class.new(DaVinciCRDTestKit::ClientFHIRApiValidationTest) do + fhir_resource_validator do + url ENV.fetch('CRD_FHIR_RESOURCE_VALIDATOR_URL', 'http://hl7_validator_service:3500') + + cli_context do + txServer nil + displayWarnings true + disableDefaultResourceFetcher true + end + + igs('hl7.fhir.us.davinci-crd', 'hl7.fhir.us.core') + end + config( + options: { resource_type: 'Encounter' } + ) + end + end + + it 'passes if several fhir api requests return all valid resources' do + validation_request = stub_request(:post, "#{validator_url}/validate") + .to_return(status: 200, body: operation_outcome_success.to_json) + + create_fhir_api_requests( + url: "#{server_endpoint}/Encounter?_id=#{encounter_id}", + body: crd_encounter_search_bundle.to_json, + search_tag: 'id_search', + name: 'encounter_id_search' + ) + create_fhir_api_requests( + url: "#{server_endpoint}/Encounter/#{encounter_id}", + body: crd_encounter.to_json, + search_tag: 'read', + name: 'encounter_readh' + ) + create_fhir_api_requests( + url: "#{server_endpoint}/Encounter?organization=#{organization_id}", + body: crd_encounter_search_bundle_multiple_entries.to_json, + search_tag: 'organization_search', + name: 'encounter_organization_search' + ) + create_fhir_api_requests( + url: "#{server_endpoint}/Encounter?_id=#{encounter_id}&_include=Encounter:location", + body: crd_encounter_search_bundle_with_location.to_json, + search_tag: 'include_location_search', + name: 'encounter_include_search' + ) + + result = run(test) + expect(result.result).to eq('pass') + expect(validation_request).to have_been_made.times(2) + end + + it 'fails if any fhir api requests return invalid resources' do + validation_request = stub_request(:post, "#{validator_url}/validate") + .to_return(status: 200, body: operation_outcome_failure.to_json) + + create_fhir_api_requests( + url: "#{server_endpoint}/Encounter?_id=#{encounter_id}", + body: crd_encounter_search_bundle.to_json, + search_tag: 'id_search', + name: 'encounter_id_search' + ) + create_fhir_api_requests( + url: "#{server_endpoint}/Encounter?organization=#{organization_id}", + body: crd_encounter_search_bundle_multiple_entries.to_json, + search_tag: 'organization_search', + name: 'encounter_organization_search' + ) + create_fhir_api_requests( + url: "#{server_endpoint}/Encounter?_id=#{encounter_id}&_include=Encounter:location", + body: crd_encounter_search_bundle_with_location.to_json, + search_tag: 'include_location_search', + name: 'encounter_include_search' + ) + + result = run(test) + + expect(result.result).to eq('fail') + expect(result.result_message).to match('2/2 Encounter resources returned from previous') + expect(validation_request).to have_been_made.times(2) + end + + it 'skips if no fhir api requests were made' do + result = run(test) + + expect(result.result).to eq('skip') + expect(result.result_message).to eq('No FHIR api requests were made') + end + + it 'passes if at least one fhir api request returns a 200 even if one returns a non 200' do + validation_request = stub_request(:post, "#{validator_url}/validate") + .to_return(status: 200, body: operation_outcome_success.to_json) + + create_fhir_api_requests( + url: "#{server_endpoint}/Encounter?_id=#{encounter_id}", + body: operation_outcome_failure.to_json, + search_tag: 'id_search', + name: 'encounter_id_search', + status: 400 + ) + create_fhir_api_requests( + url: "#{server_endpoint}/Encounter?organization=#{organization_id}", + body: crd_encounter_search_bundle_multiple_entries.to_json, + search_tag: 'organization_search', + name: 'encounter_organization_search' + ) + create_fhir_api_requests( + url: "#{server_endpoint}/Encounter?_id=#{encounter_id}&_include=Encounter:location", + body: crd_encounter_search_bundle_with_location.to_json, + search_tag: 'include_location_search', + name: 'encounter_include_search' + ) + + result = run(test) + + expect(result.result).to eq('pass') + expect(validation_request).to have_been_made.times(2) + end + + it 'skips if all fhir api requests return a non 200' do + create_fhir_api_requests( + url: "#{server_endpoint}/Encounter?_id=#{encounter_id}", + body: operation_outcome_failure.to_json, + search_tag: 'id_search', + name: 'encounter_id_search', + status: 400 + ) + create_fhir_api_requests( + url: "#{server_endpoint}/Encounter?organization=#{organization_id}", + body: operation_outcome_failure.to_json, + search_tag: 'organization_search', + name: 'encounter_organization_search', + status: 400 + ) + create_fhir_api_requests( + url: "#{server_endpoint}/Encounter?_id=#{encounter_id}&_include=Encounter:location", + body: operation_outcome_failure.to_json, + search_tag: 'include_location_search', + name: 'encounter_include_search', + status: 400 + ) + + result = run(test) + + expect(result.result).to eq('skip') + expect(result.result_message).to match( + 'There were no successful FHIR API requests made in previous tests to use in validation.' + ) + end + end +end diff --git a/spec/davinci_crd_test_kit/coverage_information_system_action_across_hooks_validation_test_spec.rb b/spec/davinci_crd_test_kit/coverage_information_system_action_across_hooks_validation_test_spec.rb new file mode 100644 index 0000000..05be016 --- /dev/null +++ b/spec/davinci_crd_test_kit/coverage_information_system_action_across_hooks_validation_test_spec.rb @@ -0,0 +1,51 @@ +RSpec.describe DaVinciCRDTestKit::CoverageInformationSystemActionAcrossHooksValidationTest do + let(:runnable_across) do + id = 'crd_server-crd_server_hooks-crd_server_required_card_response_validation' \ + '-crd_coverage_info_system_action_across_hooks_validation' + Inferno::Repositories::Tests.new.find(id) + end + let(:runnable_within) do + Inferno::Repositories::Tests.new + .find('crd_server-crd_server_hooks-crd_server_appointment_book-crd_coverage_info_system_action_validation') + end + let(:session_data_repo) { Inferno::Repositories::SessionData.new } + let(:results_repo) { Inferno::Repositories::Results.new } + let(:test_session) { repo_create(:test_session, test_suite_id: 'crd_server') } + let(:valid_response_body) do + File.read(File.join(__dir__, '..', 'fixtures', 'crd_authorization_hook_response.json')) + end + let(:valid_coverage_info_system_action) { JSON.parse(valid_response_body)['systemActions'].first } + let(:base_url) { 'http://example.com/' } + + def run(runnable, inputs = {}) + test_run_params = { test_session_id: test_session.id }.merge(runnable.reference_hash) + test_run = Inferno::Repositories::TestRuns.new.create(test_run_params) + inputs.each do |name, value| + session_data_repo.save( + test_session_id: test_session.id, + name: name == :coverage_info ? :appointment_book_coverage_info : name, + value:, + type: runnable.config.input_type(name) + ) + end + Inferno::TestRunner.new(test_session:, test_run:).run(runnable) + end + + before do + allow_any_instance_of(runnable_within).to receive(:hook_name).and_return('appointment-book') + allow_any_instance_of(runnable_within).to receive(:assert_valid_resource).and_return(true) + end + + it 'passes if a valid coverage info system action is present' do + run(runnable_within, coverage_info: [valid_coverage_info_system_action].to_json, base_url:) + result = run(runnable_across) + expect(result.result).to eq('pass') + end + + it 'skips if no valid coverage info system action present' do + run(runnable_within, coverage_info: [], base_url:) + result = run(runnable_across) + expect(result.result).to eq('skip') + expect(result.result_message).to match(/None of the hooks invoked returned valid Coverage Info system actions/) + end +end diff --git a/spec/davinci_crd_test_kit/coverage_information_system_action_received_test_spec.rb b/spec/davinci_crd_test_kit/coverage_information_system_action_received_test_spec.rb new file mode 100644 index 0000000..51c1e90 --- /dev/null +++ b/spec/davinci_crd_test_kit/coverage_information_system_action_received_test_spec.rb @@ -0,0 +1,62 @@ +RSpec.describe DaVinciCRDTestKit::CoverageInformationSystemActionReceivedTest do + let(:runnable) { Inferno::Repositories::Tests.new.find('crd_coverage_info_system_action_received') } + let(:session_data_repo) { Inferno::Repositories::SessionData.new } + let(:test_session) { repo_create(:test_session, test_suite_id: 'crd_server') } + let(:coverage_info_system_action) do + json = File.read(File.join(__dir__, '..', 'fixtures', 'crd_authorization_hook_response.json')) + JSON.parse(json)['systemActions'].first + end + let(:other_system_action) do + json = File.read(File.join(__dir__, '..', 'fixtures', 'other_system_action.json')) + JSON.parse(json) + end + + def run(runnable, inputs = {}) + test_run_params = { test_session_id: test_session.id }.merge(runnable.reference_hash) + test_run = Inferno::Repositories::TestRuns.new.create(test_run_params) + inputs.each do |name, value| + session_data_repo.save( + test_session_id: test_session.id, + name:, + value:, + type: runnable.config.input_type(name) + ) + end + Inferno::TestRunner.new(test_session:, test_run:).run(runnable) + end + + before do + allow_any_instance_of(runnable).to receive(:hook_name).and_return('appointment-book') + end + + it 'passes if coverage information system action is provided' do + result = run(runnable, valid_system_actions: [coverage_info_system_action].to_json) + expect(result.result).to eq('pass') + end + + it 'skips if valid_system_actions not present' do + result = run(runnable) + expect(result.result).to eq('skip') + expect(result.result_message).to match(/'valid_system_actions' is nil, skipping test/) + end + + it 'fails if valid_system_actions is not json' do + result = run(runnable, valid_system_actions: '[[') + expect(result.result).to eq('fail') + expect(result.result_message).to match(/Invalid JSON/) + end + + it 'fails if coverage information system action is missing' do + result = run(runnable, valid_system_actions: [other_system_action].to_json) + expect(result.result).to eq('fail') + expect(result.result_message).to match(/Coverage Information system action was not returned/) + end + + it 'persists output' do + result = run(runnable, valid_system_actions: [coverage_info_system_action].to_json) + expect(result.result).to eq('pass') + + persisted_coverage_info = session_data_repo.load(test_session_id: test_session.id, name: :coverage_info) + expect(persisted_coverage_info).to eq([coverage_info_system_action].to_json) + end +end diff --git a/spec/davinci_crd_test_kit/coverage_information_system_action_validation_test_spec.rb b/spec/davinci_crd_test_kit/coverage_information_system_action_validation_test_spec.rb new file mode 100644 index 0000000..2b71be8 --- /dev/null +++ b/spec/davinci_crd_test_kit/coverage_information_system_action_validation_test_spec.rb @@ -0,0 +1,144 @@ +RSpec.describe DaVinciCRDTestKit::CoverageInformationSystemActionValidationTest do + let(:runnable) { Inferno::Repositories::Tests.new.find('crd_coverage_info_system_action_validation') } + let(:session_data_repo) { Inferno::Repositories::SessionData.new } + let(:results_repo) { Inferno::Repositories::Results.new } + let(:test_session) { repo_create(:test_session, test_suite_id: 'crd_server') } + let(:valid_coverage_info_system_action) do + json = File.read(File.join(__dir__, '..', 'fixtures', 'crd_authorization_hook_response.json')) + JSON.parse(json)['systemActions'].first + end + + def run(runnable, inputs = {}) + test_run_params = { test_session_id: test_session.id }.merge(runnable.reference_hash) + test_run = Inferno::Repositories::TestRuns.new.create(test_run_params) + inputs.each do |name, value| + session_data_repo.save( + test_session_id: test_session.id, + name:, + value:, + type: runnable.config.input_type(name) + ) + end + Inferno::TestRunner.new(test_session:, test_run:).run(runnable) + end + + before do + allow_any_instance_of(runnable).to receive(:hook_name).and_return('appointment-book') + allow_any_instance_of(runnable).to receive(:assert_valid_resource).and_return(true) + end + + def entity_result_message + results_repo.current_results_for_test_session_and_runnables(test_session.id, [runnable]) + .first + .messages + .first + end + + context 'when resource extension has multiple repetions applying to the same coverage' do + it 'passes if coverage-assertion-ids are the same and satisfied-pa-ids are the same' do + dup_action = valid_coverage_info_system_action.deep_dup + ext = dup_action['resource']['extension'].first.deep_dup + dup_action['resource']['extension'] << ext + + result = run(runnable, coverage_info: [dup_action].to_json) + expect(result.result).to eq('pass') + end + + it 'fails if coverage-assertion-ids are distinct' do + dup_action = valid_coverage_info_system_action.deep_dup + ext = dup_action['resource']['extension'].first.deep_dup + assertion_id = ext['extension'].find { |extension| extension['url'] == 'coverage-assertion-id' } + assertion_id['valueString'] = 'asdf' + dup_action['resource']['extension'] << ext + + result = run(runnable, coverage_info: [dup_action].to_json) + expect(result.result).to eq('fail') + expect(entity_result_message.message).to match(/different coverage-assertion-ids/) + end + + it 'fails if satisfied-pa-ids are distinct' do + dup_action = valid_coverage_info_system_action.deep_dup + ext = dup_action['resource']['extension'].first.deep_dup + satisfied_pa_id = ext['extension'].find { |extension| extension['url'] == 'satisfied-pa-id' } + satisfied_pa_id['valueString'] = 'asdf' + dup_action['resource']['extension'] << ext + + result = run(runnable, coverage_info: [dup_action].to_json) + expect(result.result).to eq('fail') + expect(entity_result_message.message).to match(/different satisfied-pa-ids/) + end + end + + context 'when resource extension has repetions referencing differing coverage' do + it 'passes if coverage-assertion-ids and satisfied-pa-ids are distinct across coverages' do + dup_action = valid_coverage_info_system_action.deep_dup + ext = dup_action['resource']['extension'].first.deep_dup + coverage = ext['extension'].find { |extension| extension['url'] == 'coverage' } + coverage['valueReference']['reference'] = 'http://example.org/fhir/Coverage/asdf' + assertion_id = ext['extension'].find { |extension| extension['url'] == 'coverage-assertion-id' } + assertion_id['valueString'] = 'asdf' + satisfied_pa_id = ext['extension'].find { |extension| extension['url'] == 'satisfied-pa-id' } + satisfied_pa_id['valueString'] = 'asdf' + dup_action['resource']['extension'] << ext + + result = run(runnable, coverage_info: [dup_action].to_json) + expect(result.result).to eq('pass') + end + + it 'fails if coverage-assertion-ids are the same across coverages' do + dup_action = valid_coverage_info_system_action.deep_dup + ext = dup_action['resource']['extension'].first.deep_dup + coverage = ext['extension'].find { |extension| extension['url'] == 'coverage' } + coverage['valueReference']['reference'] = 'http://example.org/fhir/Coverage/asdf' + dup_action['resource']['extension'] << ext + + result = run(runnable, coverage_info: [dup_action].to_json) + expect(result.result).to eq('fail') + expect(entity_result_message.message).to match(/SHALL have distinct coverage-assertion-ids/) + end + + it 'fails if satisfied-pa-ids are the same across coverages' do + dup_action = valid_coverage_info_system_action.deep_dup + ext = dup_action['resource']['extension'].first.deep_dup + coverage = ext['extension'].find { |extension| extension['url'] == 'coverage' } + coverage['valueReference']['reference'] = 'http://example.org/fhir/Coverage/asdf' + assertion_id = ext['extension'].find { |extension| extension['url'] == 'coverage-assertion-id' } + assertion_id['valueString'] = 'asdf' + dup_action['resource']['extension'] << ext + + result = run(runnable, coverage_info: [dup_action].to_json) + expect(result.result).to eq('fail') + expect(entity_result_message.message).to match(/SHALL have distinct satisfied-pa-ids/) + end + end + + it 'skips if coverage_info_system_actions not provided' do + result = run(runnable) + expect(result.result).to eq('skip') + expect(result.result_message).to match(/'coverage_info' is nil, skipping test/) + end + + it 'fails if coverage_info input is not valid json' do + result = run(runnable, coverage_info: '[[') + expect(result.result).to eq('fail') + expect(result.result_message).to match(/Invalid JSON/) + end + + it 'fails if a coverage info system action type is missing' do + dup_action = valid_coverage_info_system_action.deep_dup + dup_action.delete('type') + + result = run(runnable, coverage_info: [dup_action].to_json) + expect(result.result).to eq('fail') + expect(entity_result_message.message).to match(/`type` field is missing/) + end + + it 'fails if a coverage info system action type is not update' do + dup_action = valid_coverage_info_system_action.deep_dup + dup_action['type'] = 'create' + + result = run(runnable, coverage_info: [dup_action].to_json) + expect(result.result).to eq('fail') + expect(entity_result_message.message).to match(/`type` must be `update`/) + end +end diff --git a/spec/davinci_crd_test_kit/create_or_update_coverage_info_response_validation_test_spec.rb b/spec/davinci_crd_test_kit/create_or_update_coverage_info_response_validation_test_spec.rb new file mode 100644 index 0000000..88e0792 --- /dev/null +++ b/spec/davinci_crd_test_kit/create_or_update_coverage_info_response_validation_test_spec.rb @@ -0,0 +1,92 @@ +RSpec.describe DaVinciCRDTestKit::CreateOrUpdateCoverageInfoResponseValidationTest do + let(:runnable) { Inferno::Repositories::Tests.new.find('crd_create_or_update_coverage_info_response_validation') } + let(:session_data_repo) { Inferno::Repositories::SessionData.new } + let(:results_repo) { Inferno::Repositories::Results.new } + let(:test_session) { repo_create(:test_session, test_suite_id: 'crd_server') } + let(:valid_cards) do + json = File.read(File.join(__dir__, '..', 'fixtures', 'valid_cards.json')) + JSON.parse(json) + end + let(:cards_with_suggestions) { valid_cards.filter { |card| card['suggestions'].present? } } + let(:valid_system_actions) do + cards_with_suggestions.filter { |card| card['summary'].include?('Create or Update Coverage') } + .flat_map { |card| card['suggestions'].flat_map { |suggestion| suggestion['actions'] } } + end + + def run(runnable, inputs = {}) + test_run_params = { test_session_id: test_session.id }.merge(runnable.reference_hash) + test_run = Inferno::Repositories::TestRuns.new.create(test_run_params) + inputs.each do |name, value| + session_data_repo.save( + test_session_id: test_session.id, + name:, + value:, + type: runnable.config.input_type(name) + ) + end + Inferno::TestRunner.new(test_session:, test_run:).run(runnable) + end + + def entity_result_message + results_repo.current_results_for_test_session_and_runnables(test_session.id, [runnable]) + .first + .messages + .first + end + + before do + allow_any_instance_of(runnable).to receive(:resource_is_valid?).and_return(true) + end + + it 'passes if valid create or update coverage info cards are received' do + result = run(runnable, valid_cards_with_suggestions: cards_with_suggestions.to_json, + valid_system_actions: [].to_json) + + expect(result.result).to eq('pass') + end + + it 'passes if valid create or update coverage info system actions are received' do + result = run(runnable, valid_cards_with_suggestions: [].to_json, valid_system_actions: valid_system_actions.to_json) + + expect(result.result).to eq('pass') + end + + it 'skips if valid_cards_with_suggestions not present' do + result = run(runnable, valid_system_actions: [].to_json) + expect(result.result).to eq('skip') + expect(result.result_message).to match(/'valid_cards_with_suggestions' is nil, skipping test/) + end + + it 'skips if valid_system_actions not present' do + result = run(runnable, valid_cards_with_suggestions: cards_with_suggestions.to_json) + expect(result.result).to eq('skip') + expect(result.result_message).to match(/'valid_system_actions' is nil, skipping test/) + end + + it 'fails if valid_cards_with_suggestions is not valid json' do + result = run(runnable, valid_cards_with_suggestions: '[[', valid_system_actions: [].to_json) + expect(result.result).to eq('fail') + expect(result.result_message).to match(/Invalid JSON/) + end + + it 'fails if valid_system_actions is not valid json' do + result = run(runnable, valid_cards_with_suggestions: [].to_json, valid_system_actions: '[[') + expect(result.result).to eq('fail') + expect(result.result_message).to match(/Invalid JSON/) + end + + it 'skips if create or update coverage info card or system action not present' do + cards_with_suggestions.reject! { |card| card['summary'].include?('Create or Update Coverage') } + system_action = { + type: 'delete', + description: 'Remove name-brand prescription', + resourceId: ['MedicationRequest/2222'] + } + result = run(runnable, valid_cards_with_suggestions: cards_with_suggestions.to_json, + valid_system_actions: [system_action].to_json) + expect(result.result).to eq('skip') + expect(result.result_message).to match( + /does not contain any Create or Update Coverage Information cards or system actions/ + ) + end +end diff --git a/spec/davinci_crd_test_kit/decode_auth_token_test_spec.rb b/spec/davinci_crd_test_kit/decode_auth_token_test_spec.rb new file mode 100644 index 0000000..3e761dd --- /dev/null +++ b/spec/davinci_crd_test_kit/decode_auth_token_test_spec.rb @@ -0,0 +1,90 @@ +require_relative '../../lib/davinci_crd_test_kit/client_tests/decode_auth_token_test' +require_relative '../../lib/davinci_crd_test_kit/jwt_helper' + +RSpec.describe DaVinciCRDTestKit::DecodeAuthTokenTest do + let(:session_data_repo) { Inferno::Repositories::SessionData.new } + let(:test_session) { repo_create(:test_session, test_suite_id: 'crd_client') } + let(:test) { Inferno::Repositories::Tests.new.find('crd_decode_auth_token') } + let(:jwt_helper) { Class.new(DaVinciCRDTestKit::JwtHelper) } + + let(:example_client_url) { 'https://cds.example.org' } + let(:base_url) { "#{Inferno::Application['base_url']}/custom/crd_client" } + let(:appointment_book_url) { "#{base_url}/cds-services/appointment-book-service" } + + let(:appointment_book_hook_request) do + File.read(File.join( + __dir__, '..', 'fixtures', 'appointment_book_hook_request.json' + )) + end + + def run(runnable, inputs = {}) + test_run_params = { test_session_id: test_session.id }.merge(runnable.reference_hash) + test_run = Inferno::Repositories::TestRuns.new.create(test_run_params) + inputs.each do |name, value| + session_data_repo.save( + test_session_id: test_session.id, + name:, + value:, + type: runnable.config.input_type(name) + ) + end + Inferno::TestRunner.new(test_session:, test_run:).run(runnable) + end + + def create_appointment_hook_request(body: nil, status: 200, headers: nil, auth_header: nil) + headers ||= [ + { + type: 'request', + name: 'Authorization', + value: auth_header + } + ] + repo_create( + :request, + name: 'hook_request', + direction: 'incoming', + url: 'http://example.com/custom/crd_client/cds-services/appointment-book-service', + test_session_id: test_session.id, + request_body: body.is_a?(Hash) ? body.to_json : body, + status:, + headers: + ) + end + + it 'passes if valid authorization header included in request' do + token = jwt_helper.build( + aud: appointment_book_url, + iss: example_client_url, + jku: "#{example_client_url}/jwks.json", + encryption_method: 'RS384' + ) + + create_appointment_hook_request(body: appointment_book_hook_request, auth_header: "Bearer #{token}") + + result = run(test) + expect(result.result).to eq('pass') + end + + it 'skips if no authorization header included in request' do + create_appointment_hook_request(body: appointment_book_hook_request, auth_header: nil) + + result = run(test) + expect(result.result).to eq('skip') + expect(result.result_message).to eq('Request does not include an Authorization header') + end + + it 'fails if authorization header does not present the JWT as a `Bearer` token' do + token = jwt_helper.build( + aud: appointment_book_url, + iss: example_client_url, + jku: "#{example_client_url}/jwks.json", + encryption_method: 'RS384' + ) + + create_appointment_hook_request(body: appointment_book_hook_request, auth_header: token) + + result = run(test) + expect(result.result).to eq('fail') + expect(result.result_message).to eq('Authorization token must be a JWT presented as a `Bearer` token') + end +end diff --git a/spec/davinci_crd_test_kit/encounter_discharge_receive_request_test_spec.rb b/spec/davinci_crd_test_kit/encounter_discharge_receive_request_test_spec.rb new file mode 100644 index 0000000..b2f73e6 --- /dev/null +++ b/spec/davinci_crd_test_kit/encounter_discharge_receive_request_test_spec.rb @@ -0,0 +1,287 @@ +require_relative '../../lib/davinci_crd_test_kit/client_tests/encounter_discharge_receive_request_test' +require_relative '../request_helper' + +RSpec.describe DaVinciCRDTestKit::EncounterDischargeReceiveRequestTest do + include Rack::Test::Methods + include RequestHelpers + + let(:suite) { Inferno::Repositories::TestSuites.new.find('crd_client') } + let(:test) { Inferno::Repositories::Tests.new.find('crd_encounter_discharge_request') } + let(:session_data_repo) { Inferno::Repositories::SessionData.new } + let(:results_repo) { Inferno::Repositories::Results.new } + let(:test_session) { repo_create(:test_session, test_suite_id: 'crd_client') } + let(:jwt_helper) { Class.new(DaVinciCRDTestKit::JwtHelper) } + + let(:example_client_url) { 'https://cds.example.org' } + let(:base_url) { "#{Inferno::Application['base_url']}/custom/crd_client" } + let(:encounter_discharge_url) { "#{base_url}/cds-services/encounter-discharge-service" } + let(:client_fhir_server) { 'https://example/r4' } + let(:patient_id) { 'example' } + let(:encounter_discharge_selected_response_types) { ['instructions', 'coverage_information', 'external_reference'] } + + let(:server_endpoint) { '/custom/crd_client/cds-services/encounter-discharge-service' } + let(:body) do + JSON.parse(File.read(File.join( + __dir__, '..', 'fixtures', 'encounter_discharge_hook_request.json' + ))) + end + + let(:crd_encounter) do + JSON.parse(File.read(File.join( + __dir__, '..', 'fixtures', 'crd_encounter_example.json' + ))) + end + let(:crd_coverage) do + JSON.parse(File.read(File.join( + __dir__, '..', 'fixtures', 'crd_coverage_example.json' + ))) + end + let(:crd_coverage_bundle) do + bundle = FHIR::Bundle.new(type: 'searchset') + bundle.entry.append(FHIR::Bundle::Entry.new( + fullUrl: 'https://example.com/base/Coverage/coverage_example', + resource: FHIR.from_contents(crd_coverage.to_json) + )) + bundle + end + + def run(runnable, inputs = {}) + test_run_params = { test_session_id: test_session.id }.merge(runnable.reference_hash) + test_run = Inferno::Repositories::TestRuns.new.create(test_run_params) + inputs.each do |name, value| + session_data_repo.save( + test_session_id: test_session.id, + name:, + value:, + type: runnable.config.input_type(name) + ) + end + Inferno::TestRunner.new(test_session:, test_run:).run(runnable) + end + + it 'passes and responds 200 if request sent to the provided URL and jwt `iss` claim matches the given`iss`' do + allow(test).to receive(:suite).and_return(suite) + + token = jwt_helper.build( + aud: encounter_discharge_url, + iss: example_client_url, + jku: "#{example_client_url}/jwks.json", + encryption_method: 'RS384' + ) + + result = run(test, iss: example_client_url, encounter_discharge_selected_response_types:) + + expect(result.result).to eq('wait') + + body['prefetch'] = { 'coverage' => crd_coverage, 'encounter' => crd_encounter } + header('Authorization', "Bearer #{token}") + post_json(server_endpoint, body) + + expect(last_response).to be_ok + result = results_repo.find(result.id) + expect(result.result).to eq('pass') + end + + it 'returns cards and systemActions and uses client information to build request' do + allow(test).to receive(:suite).and_return(suite) + + token = jwt_helper.build( + aud: encounter_discharge_url, + iss: example_client_url, + jku: "#{example_client_url}/jwks.json", + encryption_method: 'RS384' + ) + + run(test, iss: example_client_url, encounter_discharge_selected_response_types:) + + body['prefetch'] = { 'coverage' => crd_coverage, 'encounter' => crd_encounter } + header('Authorization', "Bearer #{token}") + post_json(server_endpoint, body) + + expect(last_response).to be_ok + + card_response = JSON.parse(last_response.body) + cards = card_response['cards'] + system_actions = card_response['systemActions'] + + expect(cards.length).to eq(2) + expect(system_actions.length).to eq(1) + expect(system_actions.first['resource']['id']).to eq('example') + + encounter_extension = system_actions.first['resource']['extension'] + coverage_extension = encounter_extension.first['extension'].first + + expect(coverage_extension['url']).to eq('coverage') + expect(coverage_extension['valueReference']['reference']).to eq("Coverage/#{crd_coverage['id']}") + end + + it 'queries the client\'s FHIR server if coverage is not present in the prefetch' do + coverage_search_request = stub_request(:get, + "#{client_fhir_server}/Coverage?patient=#{patient_id}&status=active") + .with( + headers: { 'Authorization' => 'Bearer SAMPLE_TOKEN' } + ) + .to_return(status: 200, body: crd_coverage_bundle.to_json) + encounter_request = stub_request(:get, + "#{client_fhir_server}/Encounter/example") + .with( + headers: { 'Authorization' => 'Bearer SAMPLE_TOKEN' } + ) + .to_return(status: 200, body: crd_encounter.to_json) + + allow(test).to receive(:suite).and_return(suite) + + token = jwt_helper.build( + aud: encounter_discharge_url, + iss: example_client_url, + jku: "#{example_client_url}/jwks.json", + encryption_method: 'RS384' + ) + + run(test, iss: example_client_url, encounter_discharge_selected_response_types:) + + header('Authorization', "Bearer #{token}") + post_json(server_endpoint, body) + + expect(last_response).to be_ok + + card_response = JSON.parse(last_response.body) + cards = card_response['cards'] + system_actions = card_response['systemActions'] + + expect(cards.length).to eq(2) + expect(system_actions.length).to eq(1) + expect(system_actions.first['resource']['id']).to eq('example') + + encounter_extension = system_actions.first['resource']['extension'] + coverage_extension = encounter_extension.first['extension'].first + + expect(coverage_extension['url']).to eq('coverage') + expect(coverage_extension['valueReference']['reference']).to eq("Coverage/#{crd_coverage['id']}") + expect(coverage_search_request).to have_been_made + expect(encounter_request).to have_been_made + end + + it 'waits and responds with 500 if request sent to the provided URL and jwt `iss` claim mismatches the given `iss`' do + allow(test).to receive(:suite).and_return(suite) + + token = jwt_helper.build( + aud: encounter_discharge_url, + iss: example_client_url, + jku: "#{example_client_url}/jwks.json", + encryption_method: 'RS384' + ) + + result = run(test, iss: 'example.com', encounter_discharge_selected_response_types:) + + expect(result.result).to eq('wait') + + body['prefetch'] = { 'coverage' => crd_coverage, 'encounter' => crd_encounter } + header('Authorization', "Bearer #{token}") + post_json(server_endpoint, body.to_json) + + expect(last_response).to be_server_error + expect(last_response.body).to match(/find test run with identifier/) + result = results_repo.find(result.id) + expect(result.result).to eq('wait') + end + + it 'waits and responds with 500 if request sent to the provided URL contains the wrong hook name' do + allow(test).to receive(:suite).and_return(suite) + + token = jwt_helper.build( + aud: encounter_discharge_url, + iss: example_client_url, + jku: "#{example_client_url}/jwks.json", + encryption_method: 'RS384' + ) + + result = run(test, iss: 'example.com', encounter_discharge_selected_response_types:) + + expect(result.result).to eq('wait') + + body['prefetch'] = { 'coverage' => crd_coverage } + body['hook'] = 'incorrect-hook' + header('Authorization', "Bearer #{token}") + post_json(server_endpoint, body.to_json) + + expect(last_response).to be_server_error + expect(last_response.body).to match(/find test run with identifier/) + result = results_repo.find(result.id) + expect(result.result).to eq('wait') + end + + it 'returns default cards when no encounter_discharge_selected_response_types selected' do + allow(test).to receive(:suite).and_return(suite) + + token = jwt_helper.build( + aud: encounter_discharge_url, + iss: example_client_url, + jku: "#{example_client_url}/jwks.json", + encryption_method: 'RS384' + ) + + run(test, iss: example_client_url, encounter_discharge_selected_response_types: []) + + body['prefetch'] = { 'coverage' => crd_coverage } + header('Authorization', "Bearer #{token}") + post_json(server_endpoint, body) + + expect(last_response).to be_ok + + card_response = JSON.parse(last_response.body) + cards = card_response['cards'] + system_actions = card_response['systemActions'] + + expect(cards.length).to eq(1) + expect(system_actions).to be_nil + expect(cards.first['summary']).to eq('Encounter Discharge Instructions Card') + end + + it 'successfully returns all supported cards when all selected_response_type options are selected' do + encounter_request = stub_request(:get, "#{client_fhir_server}/Encounter/example") + .with(headers: { 'Authorization' => 'Bearer SAMPLE_TOKEN' }) + .to_return(status: 200, body: crd_encounter.to_json) + + allow(test).to receive(:suite).and_return(suite) + + token = jwt_helper.build( + aud: encounter_discharge_url, + iss: example_client_url, + jku: "#{example_client_url}/jwks.json", + encryption_method: 'RS384' + ) + + run(test, iss: example_client_url, + encounter_discharge_selected_response_types: encounter_discharge_selected_response_types + + ['request_form_completion', 'create_update_coverage_info', 'launch_smart_app']) + + body['prefetch'] = { 'coverage' => crd_coverage } + header('Authorization', "Bearer #{token}") + post_json(server_endpoint, body) + + expect(last_response).to be_ok + + card_response = JSON.parse(last_response.body) + cards = card_response['cards'] + system_actions = card_response['systemActions'] + + expect(cards.length).to eq(5) + + expect(cards.first['summary']).to eq('Encounter Discharge Request Form Completion Card') + expect(cards[1]['summary']).to eq('Encounter Discharge Launch SMART Application Card') + expect(cards[2]['summary']).to eq('Encounter Discharge External Reference Card') + expect(cards[3]['summary']).to eq('Encounter Discharge Create/Update Coverage Information Card') + expect(cards[4]['summary']).to eq('Encounter Discharge Instructions Card') + + expect(system_actions.length).to eq(1) + expect(system_actions.first['resource']['id']).to eq('example') + + encounter_extension = system_actions.first['resource']['extension'] + coverage_extension = encounter_extension.first['extension'].first + + expect(coverage_extension['url']).to eq('coverage') + expect(coverage_extension['valueReference']['reference']).to eq("Coverage/#{crd_coverage['id']}") + expect(encounter_request).to have_been_made + end +end diff --git a/spec/davinci_crd_test_kit/encounter_start_receive_request_test_spec.rb b/spec/davinci_crd_test_kit/encounter_start_receive_request_test_spec.rb new file mode 100644 index 0000000..8f90f8e --- /dev/null +++ b/spec/davinci_crd_test_kit/encounter_start_receive_request_test_spec.rb @@ -0,0 +1,286 @@ +require_relative '../../lib/davinci_crd_test_kit/client_tests/encounter_start_receive_request_test' +require_relative '../request_helper' + +RSpec.describe DaVinciCRDTestKit::EncounterStartReceiveRequestTest do + include Rack::Test::Methods + include RequestHelpers + + let(:suite) { Inferno::Repositories::TestSuites.new.find('crd_client') } + let(:test) { Inferno::Repositories::Tests.new.find('crd_encounter_start_request') } + let(:session_data_repo) { Inferno::Repositories::SessionData.new } + let(:results_repo) { Inferno::Repositories::Results.new } + let(:test_session) { repo_create(:test_session, test_suite_id: 'crd_client') } + let(:jwt_helper) { Class.new(DaVinciCRDTestKit::JwtHelper) } + + let(:example_client_url) { 'https://cds.example.org' } + let(:base_url) { "#{Inferno::Application['base_url']}/custom/crd_client" } + let(:encounter_start_url) { "#{base_url}/cds-services/encounter-start-service" } + let(:client_fhir_server) { 'https://example/r4' } + let(:patient_id) { 'example' } + let(:encounter_start_selected_response_types) { ['instructions', 'coverage_information', 'external_reference'] } + + let(:server_endpoint) { '/custom/crd_client/cds-services/encounter-start-service' } + let(:body) do + JSON.parse(File.read(File.join( + __dir__, '..', 'fixtures', 'encounter_start_hook_request.json' + ))) + end + + let(:crd_encounter) do + JSON.parse(File.read(File.join( + __dir__, '..', 'fixtures', 'crd_encounter_example.json' + ))) + end + let(:crd_coverage) do + JSON.parse(File.read(File.join( + __dir__, '..', 'fixtures', 'crd_coverage_example.json' + ))) + end + let(:crd_coverage_bundle) do + bundle = FHIR::Bundle.new(type: 'searchset') + bundle.entry.append(FHIR::Bundle::Entry.new( + fullUrl: 'https://example.com/base/Coverage/coverage_example', + resource: FHIR.from_contents(crd_coverage.to_json) + )) + bundle + end + + def run(runnable, inputs = {}) + test_run_params = { test_session_id: test_session.id }.merge(runnable.reference_hash) + test_run = Inferno::Repositories::TestRuns.new.create(test_run_params) + inputs.each do |name, value| + session_data_repo.save( + test_session_id: test_session.id, + name:, + value:, + type: runnable.config.input_type(name) + ) + end + Inferno::TestRunner.new(test_session:, test_run:).run(runnable) + end + + it 'passes and responds 200 if request sent to the provided URL and jwt `iss` claim matches the given`iss`' do + allow(test).to receive(:suite).and_return(suite) + + token = jwt_helper.build( + aud: encounter_start_url, + iss: example_client_url, + jku: "#{example_client_url}/jwks.json", + encryption_method: 'RS384' + ) + + result = run(test, iss: example_client_url, encounter_start_selected_response_types:) + + expect(result.result).to eq('wait') + + body['prefetch'] = { 'coverage' => crd_coverage, 'encounter' => crd_encounter } + header('Authorization', "Bearer #{token}") + post_json(server_endpoint, body) + expect(last_response).to be_ok + result = results_repo.find(result.id) + expect(result.result).to eq('pass') + end + + it 'returns cards and systemActions and uses client information to build request' do + allow(test).to receive(:suite).and_return(suite) + + token = jwt_helper.build( + aud: encounter_start_url, + iss: example_client_url, + jku: "#{example_client_url}/jwks.json", + encryption_method: 'RS384' + ) + + run(test, iss: example_client_url, encounter_start_selected_response_types:) + + body['prefetch'] = { 'coverage' => crd_coverage, 'encounter' => crd_encounter } + header('Authorization', "Bearer #{token}") + post_json(server_endpoint, body) + + expect(last_response).to be_ok + + card_response = JSON.parse(last_response.body) + cards = card_response['cards'] + system_actions = card_response['systemActions'] + + expect(cards.length).to eq(2) + expect(system_actions.length).to eq(1) + expect(system_actions.first['resource']['id']).to eq('example') + + encounter_extension = system_actions.first['resource']['extension'] + coverage_extension = encounter_extension.first['extension'].first + + expect(coverage_extension['url']).to eq('coverage') + expect(coverage_extension['valueReference']['reference']).to eq("Coverage/#{crd_coverage['id']}") + end + + it 'queries the client\'s FHIR server if coverage is not present in the prefetch' do + coverage_search_request = stub_request(:get, + "#{client_fhir_server}/Coverage?patient=#{patient_id}&status=active") + .with( + headers: { 'Authorization' => 'Bearer SAMPLE_TOKEN' } + ) + .to_return(status: 200, body: crd_coverage_bundle.to_json) + encounter_request = stub_request(:get, + "#{client_fhir_server}/Encounter/example") + .with( + headers: { 'Authorization' => 'Bearer SAMPLE_TOKEN' } + ) + .to_return(status: 200, body: crd_encounter.to_json) + + allow(test).to receive(:suite).and_return(suite) + + token = jwt_helper.build( + aud: encounter_start_url, + iss: example_client_url, + jku: "#{example_client_url}/jwks.json", + encryption_method: 'RS384' + ) + + run(test, iss: example_client_url, encounter_start_selected_response_types:) + + header('Authorization', "Bearer #{token}") + post_json(server_endpoint, body) + + expect(last_response).to be_ok + + card_response = JSON.parse(last_response.body) + cards = card_response['cards'] + system_actions = card_response['systemActions'] + + expect(cards.length).to eq(2) + expect(system_actions.length).to eq(1) + expect(system_actions.first['resource']['id']).to eq('example') + + encounter_extension = system_actions.first['resource']['extension'] + coverage_extension = encounter_extension.first['extension'].first + + expect(coverage_extension['url']).to eq('coverage') + expect(coverage_extension['valueReference']['reference']).to eq("Coverage/#{crd_coverage['id']}") + expect(coverage_search_request).to have_been_made + expect(encounter_request).to have_been_made + end + + it 'waits and responds with 500 if request sent to the provided URL and jwt `iss` claim mismatches the given `iss`' do + allow(test).to receive(:suite).and_return(suite) + + token = jwt_helper.build( + aud: encounter_start_url, + iss: example_client_url, + jku: "#{example_client_url}/jwks.json", + encryption_method: 'RS384' + ) + + result = run(test, iss: 'example.com', encounter_start_selected_response_types:) + + expect(result.result).to eq('wait') + + body['prefetch'] = { 'coverage' => crd_coverage, 'encounter' => crd_encounter } + header('Authorization', "Bearer #{token}") + post_json(server_endpoint, body.to_json) + + expect(last_response).to be_server_error + expect(last_response.body).to match(/find test run with identifier/) + result = results_repo.find(result.id) + expect(result.result).to eq('wait') + end + + it 'waits and responds with 500 if request sent to the provided URL contains the wrong hook name' do + allow(test).to receive(:suite).and_return(suite) + + token = jwt_helper.build( + aud: encounter_start_url, + iss: example_client_url, + jku: "#{example_client_url}/jwks.json", + encryption_method: 'RS384' + ) + + result = run(test, iss: 'example.com', encounter_start_selected_response_types:) + + expect(result.result).to eq('wait') + + body['prefetch'] = { 'coverage' => crd_coverage } + body['hook'] = 'incorrect-hook' + header('Authorization', "Bearer #{token}") + post_json(server_endpoint, body.to_json) + + expect(last_response).to be_server_error + expect(last_response.body).to match(/find test run with identifier/) + result = results_repo.find(result.id) + expect(result.result).to eq('wait') + end + + it 'returns default cards when no encounter_start_selected_response_types selected' do + allow(test).to receive(:suite).and_return(suite) + + token = jwt_helper.build( + aud: encounter_start_url, + iss: example_client_url, + jku: "#{example_client_url}/jwks.json", + encryption_method: 'RS384' + ) + + run(test, iss: example_client_url, encounter_start_selected_response_types: []) + + body['prefetch'] = { 'coverage' => crd_coverage } + header('Authorization', "Bearer #{token}") + post_json(server_endpoint, body) + + expect(last_response).to be_ok + + card_response = JSON.parse(last_response.body) + cards = card_response['cards'] + system_actions = card_response['systemActions'] + + expect(cards.length).to eq(1) + expect(system_actions).to be_nil + expect(cards.first['summary']).to eq('Encounter Start Instructions Card') + end + + it 'successfully returns all supported cards when all selected_response_type options are selected' do + encounter_request = stub_request(:get, "#{client_fhir_server}/Encounter/example") + .with(headers: { 'Authorization' => 'Bearer SAMPLE_TOKEN' }) + .to_return(status: 200, body: crd_encounter.to_json) + + allow(test).to receive(:suite).and_return(suite) + + token = jwt_helper.build( + aud: encounter_start_url, + iss: example_client_url, + jku: "#{example_client_url}/jwks.json", + encryption_method: 'RS384' + ) + + run(test, iss: example_client_url, + encounter_start_selected_response_types: encounter_start_selected_response_types + + ['request_form_completion', 'create_update_coverage_info', 'launch_smart_app']) + + body['prefetch'] = { 'coverage' => crd_coverage } + header('Authorization', "Bearer #{token}") + post_json(server_endpoint, body) + + expect(last_response).to be_ok + + card_response = JSON.parse(last_response.body) + cards = card_response['cards'] + system_actions = card_response['systemActions'] + + expect(cards.length).to eq(5) + + expect(cards.first['summary']).to eq('Encounter Start Request Form Completion Card') + expect(cards[1]['summary']).to eq('Encounter Start Launch SMART Application Card') + expect(cards[2]['summary']).to eq('Encounter Start External Reference Card') + expect(cards[3]['summary']).to eq('Encounter Start Create/Update Coverage Information Card') + expect(cards[4]['summary']).to eq('Encounter Start Instructions Card') + + expect(system_actions.length).to eq(1) + expect(system_actions.first['resource']['id']).to eq('example') + + encounter_extension = system_actions.first['resource']['extension'] + coverage_extension = encounter_extension.first['extension'].first + + expect(coverage_extension['url']).to eq('coverage') + expect(coverage_extension['valueReference']['reference']).to eq("Coverage/#{crd_coverage['id']}") + expect(encounter_request).to have_been_made + end +end diff --git a/spec/davinci_crd_test_kit/external_reference_card_across_hooks_validation_test_spec.rb b/spec/davinci_crd_test_kit/external_reference_card_across_hooks_validation_test_spec.rb new file mode 100644 index 0000000..64c30af --- /dev/null +++ b/spec/davinci_crd_test_kit/external_reference_card_across_hooks_validation_test_spec.rb @@ -0,0 +1,47 @@ +RSpec.describe DaVinciCRDTestKit::ExternalReferenceCardAcrossHooksValidationTest do + let(:runnable_across) do + id = 'crd_server-crd_server_hooks-crd_server_required_card_response_validation' \ + '-crd_external_reference_card_across_hooks_validation' + Inferno::Repositories::Tests.new.find(id) + end + let(:runnable_within) do + Inferno::Repositories::Tests.new + .find('crd_server-crd_server_hooks-crd_server_order_dispatch-crd_external_reference_card_validation') + end + let(:session_data_repo) { Inferno::Repositories::SessionData.new } + let(:results_repo) { Inferno::Repositories::Results.new } + let(:test_session) { repo_create(:test_session, test_suite_id: 'crd_server') } + let(:valid_response_body) do + File.read(File.join(__dir__, '..', 'fixtures', 'crd_authorization_hook_response.json')) + end + let(:cards) { JSON.parse(valid_response_body)['cards'] } + let(:external_ref_card) { cards.find { |card| card['links'].present? } } + let(:base_url) { 'http://example.com' } + + def run(runnable, inputs = {}) + test_run_params = { test_session_id: test_session.id }.merge(runnable.reference_hash) + test_run = Inferno::Repositories::TestRuns.new.create(test_run_params) + inputs.each do |name, value| + session_data_repo.save( + test_session_id: test_session.id, + name: name == :valid_cards_with_links ? :order_dispatch_valid_cards_with_links : name, + value:, + type: runnable.config.input_type(name) + ) + end + Inferno::TestRunner.new(test_session:, test_run:).run(runnable) + end + + it 'passes if a valid external reference card is present' do + run(runnable_within, valid_cards_with_links: [external_ref_card].to_json, base_url:) + result = run(runnable_across) + expect(result.result).to eq('pass') + end + + it 'skips if no external reference card present' do + run(runnable_within, valid_cards_with_links: [].to_json, base_url:) + result = run(runnable_across) + expect(result.result).to eq('skip') + expect(result.result_message).to match(/None of the hooks invoked returned an External Reference card./) + end +end diff --git a/spec/davinci_crd_test_kit/external_reference_card_validation_test_spec.rb b/spec/davinci_crd_test_kit/external_reference_card_validation_test_spec.rb new file mode 100644 index 0000000..d1da654 --- /dev/null +++ b/spec/davinci_crd_test_kit/external_reference_card_validation_test_spec.rb @@ -0,0 +1,48 @@ +RSpec.describe DaVinciCRDTestKit::ExternalReferenceCardValidationTest do + let(:runnable) { Inferno::Repositories::Tests.new.find('crd_external_reference_card_validation') } + let(:session_data_repo) { Inferno::Repositories::SessionData.new } + let(:results_repo) { Inferno::Repositories::Results.new } + let(:test_session) { repo_create(:test_session, test_suite_id: 'crd_server') } + let(:valid_response_body) do + File.read(File.join(__dir__, '..', 'fixtures', 'crd_authorization_hook_response.json')) + end + let(:cards) { JSON.parse(valid_response_body)['cards'] } + let(:external_ref_card) { cards.find { |card| card['links'].present? } } + + def run(runnable, inputs = {}) + test_run_params = { test_session_id: test_session.id }.merge(runnable.reference_hash) + test_run = Inferno::Repositories::TestRuns.new.create(test_run_params) + inputs.each do |name, value| + session_data_repo.save( + test_session_id: test_session.id, + name:, + value:, + type: runnable.config.input_type(name) + ) + end + Inferno::TestRunner.new(test_session:, test_run:).run(runnable) + end + + it 'passes if cards contain a valid external reference card' do + result = run(runnable, valid_cards_with_links: [external_ref_card].to_json) + expect(result.result).to eq('pass') + end + + it 'skips if valid_cards_with_links not present' do + result = run(runnable) + expect(result.result).to eq('skip') + expect(result.result_message).to match(/'valid_cards_with_links' is nil, skipping test/) + end + + it 'fails if valid_cards_with_links is not json' do + result = run(runnable, valid_cards_with_links: '[[') + expect(result.result).to eq('fail') + expect(result.result_message).to match(/Invalid JSON/) + end + + it 'fails if no external reference card present' do + result = run(runnable, valid_cards_with_links: [].to_json) + expect(result.result).to eq('fail') + expect(result.result_message).to match(/did not contain an External Reference card/) + end +end diff --git a/spec/davinci_crd_test_kit/form_completion_response_validation_test_spec.rb b/spec/davinci_crd_test_kit/form_completion_response_validation_test_spec.rb new file mode 100644 index 0000000..a4b4828 --- /dev/null +++ b/spec/davinci_crd_test_kit/form_completion_response_validation_test_spec.rb @@ -0,0 +1,86 @@ +RSpec.describe DaVinciCRDTestKit::FormCompletionResponseValidationTest do + let(:runnable) { Inferno::Repositories::Tests.new.find('crd_request_form_completion_response_validation') } + let(:session_data_repo) { Inferno::Repositories::SessionData.new } + let(:results_repo) { Inferno::Repositories::Results.new } + let(:test_session) { repo_create(:test_session, test_suite_id: 'crd_server') } + let(:valid_cards) do + json = File.read(File.join(__dir__, '..', 'fixtures', 'valid_cards.json')) + JSON.parse(json) + end + let(:cards_with_suggestions) { valid_cards.filter { |card| card['suggestions'].present? } } + let(:valid_system_actions) do + cards_with_suggestions.filter { |card| card['summary'].include?('Request Form Completion') } + .flat_map { |card| card['suggestions'].flat_map { |suggestion| suggestion['actions'] } } + end + + def run(runnable, inputs = {}) + test_run_params = { test_session_id: test_session.id }.merge(runnable.reference_hash) + test_run = Inferno::Repositories::TestRuns.new.create(test_run_params) + inputs.each do |name, value| + session_data_repo.save( + test_session_id: test_session.id, + name:, + value:, + type: runnable.config.input_type(name) + ) + end + Inferno::TestRunner.new(test_session:, test_run:).run(runnable) + end + + def entity_result_message + results_repo.current_results_for_test_session_and_runnables(test_session.id, [runnable]) + .first + .messages + .first + end + + before do + allow_any_instance_of(runnable).to receive(:resource_is_valid?).and_return(true) + end + + it 'passes if valid request form completion cards are received' do + result = run(runnable, valid_cards_with_suggestions: cards_with_suggestions.to_json, + valid_system_actions: [].to_json) + + expect(result.result).to eq('pass') + end + + it 'passes if valid request form completion system actions are received' do + result = run(runnable, valid_cards_with_suggestions: [].to_json, valid_system_actions: valid_system_actions.to_json) + + expect(result.result).to eq('pass') + end + + it 'skips if valid_cards_with_suggestions not present' do + result = run(runnable, valid_system_actions: [].to_json) + expect(result.result).to eq('skip') + expect(result.result_message).to match(/'valid_cards_with_suggestions' is nil, skipping test/) + end + + it 'skips if valid_system_actions not present' do + result = run(runnable, valid_cards_with_suggestions: cards_with_suggestions.to_json) + expect(result.result).to eq('skip') + expect(result.result_message).to match(/'valid_system_actions' is nil, skipping test/) + end + + it 'fails if valid_cards_with_suggestions is not valid json' do + result = run(runnable, valid_cards_with_suggestions: '[[', valid_system_actions: [].to_json) + expect(result.result).to eq('fail') + expect(result.result_message).to match(/Invalid JSON/) + end + + it 'fails if valid_system_actions is not valid json' do + result = run(runnable, valid_cards_with_suggestions: [].to_json, valid_system_actions: '[[') + expect(result.result).to eq('fail') + expect(result.result_message).to match(/Invalid JSON/) + end + + it 'skips if no request form completion card or system action present' do + dup_cards = cards_with_suggestions.deep_dup + dup_cards.reject! { |card| card['summary'].include?('Form Completion') } + + result = run(runnable, valid_cards_with_suggestions: dup_cards.to_json, valid_system_actions: [].to_json) + expect(result.result).to eq('skip') + expect(result.result_message).to match(/does not contain any Request Form Completion cards or system actions/) + end +end diff --git a/spec/davinci_crd_test_kit/hook_request_optional_fields_test_spec.rb b/spec/davinci_crd_test_kit/hook_request_optional_fields_test_spec.rb new file mode 100644 index 0000000..e27b59e --- /dev/null +++ b/spec/davinci_crd_test_kit/hook_request_optional_fields_test_spec.rb @@ -0,0 +1,235 @@ +require_relative '../../lib/davinci_crd_test_kit/client_tests/hook_request_optional_fields_test' +require_relative '../../lib/davinci_crd_test_kit/jwt_helper' + +RSpec.describe DaVinciCRDTestKit::HookRequestOptionalFieldsTest do + let(:session_data_repo) { Inferno::Repositories::SessionData.new } + let(:test_session) { repo_create(:test_session, test_suite_id: 'crd_client') } + let(:test) { Inferno::Repositories::Tests.new.find('crd_hook_request_optional_fields') } + let(:jwt_helper) { Class.new(DaVinciCRDTestKit::JwtHelper) } + + let(:example_client_url) { 'https://cds.example.org' } + let(:base_url) { "#{Inferno::Application['base_url']}/custom/crd_client" } + let(:appointment_book_url) { "#{base_url}/cds-services/appointment-book-service" } + let(:client_fhir_server_output) { 'https://example/r4' } + let(:client_access_token_output) { 'SAMPLE_TOKEN' } + + let(:appointment_book_hook_request) do + File.read(File.join( + __dir__, '..', 'fixtures', 'appointment_book_hook_request.json' + )) + end + let(:appointment_book_hook_request_hash) { JSON.parse(appointment_book_hook_request) } + + def run(runnable, inputs = {}) + test_run_params = { test_session_id: test_session.id }.merge(runnable.reference_hash) + test_run = Inferno::Repositories::TestRuns.new.create(test_run_params) + inputs.each do |name, value| + session_data_repo.save( + test_session_id: test_session.id, + name:, + value:, + type: runnable.config.input_type(name) + ) + end + Inferno::TestRunner.new(test_session:, test_run:).run(runnable) + end + + def create_appointment_hook_request(url: appointment_book_url, body: nil, status: 200, headers: nil, auth_header: nil) + headers ||= [ + { + type: 'request', + name: 'Authorization', + value: auth_header + } + ] + repo_create( + :request, + name: 'hook_request', + direction: 'incoming', + url:, + test_session_id: test_session.id, + request_body: body.is_a?(Hash) ? body.to_json : body, + status:, + headers: + ) + end + + it 'passes without all optional fields and produces output if it contains `fhirAuthorization` field' do + token = jwt_helper.build( + aud: appointment_book_url, + iss: example_client_url, + jku: "#{example_client_url}/jwks.json", + encryption_method: 'RS384' + ) + + create_appointment_hook_request(body: appointment_book_hook_request, auth_header: "Bearer #{token}") + + result = run(test) + expect(result.result).to eq('pass') + + outputs_hash = JSON.parse(result.output_json) + + fhir_server_output = outputs_hash.any? do |output| + output['name'] == 'client_fhir_server' && output['value'] == client_fhir_server_output + end + + access_token_output = outputs_hash.any? do |output| + output['name'] == 'client_access_token' && output['value'] == client_access_token_output + end + + expect(fhir_server_output).to be true + expect(access_token_output).to be true + end + + it 'passes and produces fhir server but not bearer token output when no `fhirAuthorization` field' do + token = jwt_helper.build( + aud: appointment_book_url, + iss: example_client_url, + jku: "#{example_client_url}/jwks.json", + encryption_method: 'RS384' + ) + + hook_request_no_fhir_auth = appointment_book_hook_request_hash.except('fhirAuthorization') + + create_appointment_hook_request(body: hook_request_no_fhir_auth, auth_header: "Bearer #{token}") + + result = run(test) + expect(result.result).to eq('pass') + + outputs_hash = JSON.parse(result.output_json) + + fhir_server_output = outputs_hash.any? do |output| + output['name'] == 'client_fhir_server' && output['value'] == client_fhir_server_output + end + + access_token_output = outputs_hash.any? do |output| + output['name'] == 'client_access_token' && output['value'].blank? + end + + expect(fhir_server_output).to be true + expect(access_token_output).to be true + end + + it 'passes and produces no output when no `fhirServer` or `fhirAuthorization` field' do + token = jwt_helper.build( + aud: appointment_book_url, + iss: example_client_url, + jku: "#{example_client_url}/jwks.json", + encryption_method: 'RS384' + ) + + hook_request_no_fhir_auth = appointment_book_hook_request_hash + .except('fhirAuthorization') + .except('fhirServer') + + create_appointment_hook_request(body: hook_request_no_fhir_auth, auth_header: "Bearer #{token}") + + result = run(test) + expect(result.result).to eq('pass') + + outputs_hash = JSON.parse(result.output_json) + + fhir_server_output = outputs_hash.any? do |output| + output['name'] == 'client_fhir_server' && output['value'].blank? + end + + access_token_output = outputs_hash.any? do |output| + output['name'] == 'client_access_token' && output['value'].blank? + end + + expect(fhir_server_output).to be true + expect(access_token_output).to be true + end + + it 'skips if no appointment-book request can be found' do + result = run(test) + expect(result.result).to eq('skip') + expect(result.result_message).to eq('Request `hook_request` was not made in a previous test as expected.') + end + + it 'fails if hook request body is not a valid json' do + token = jwt_helper.build( + aud: appointment_book_url, + iss: example_client_url, + jku: "#{example_client_url}/jwks.json", + encryption_method: 'RS384' + ) + + create_appointment_hook_request(body: 'request_body', auth_header: "Bearer #{token}") + + result = run(test) + expect(result.result).to eq('fail') + expect(result.result_message).to eq('Invalid JSON. ') + end + + it 'fails if an optional field is not of the correct type' do + token = jwt_helper.build( + aud: appointment_book_url, + iss: example_client_url, + jku: "#{example_client_url}/jwks.json", + encryption_method: 'RS384' + ) + + invalid_hook_request = appointment_book_hook_request_hash + invalid_hook_request['prefetch'] = 'Prefetch String' + + create_appointment_hook_request(body: invalid_hook_request, auth_header: "Bearer #{token}") + + result = run(test) + expect(result.result).to eq('fail') + expect(result.result_message).to eq('Hook request field prefetch is not of type Hash') + end + + it 'fails if hook request missing required field in fhirAuthorization' do + token = jwt_helper.build( + aud: appointment_book_url, + iss: example_client_url, + jku: "#{example_client_url}/jwks.json", + encryption_method: 'RS384' + ) + + invalid_hook_request = appointment_book_hook_request_hash + invalid_hook_request['fhirAuthorization'] = invalid_hook_request['fhirAuthorization'].except('access_token') + + create_appointment_hook_request(body: invalid_hook_request, auth_header: "Bearer #{token}") + + result = run(test) + expect(result.result).to eq('fail') + expect(result.result_message).to eq('`fhirAuthorization` did not contain required field: `access_token`') + end + + it 'fails if hook request fhirAuthorization `token_type` is not `Bearer`' do + token = jwt_helper.build( + aud: appointment_book_url, + iss: example_client_url, + jku: "#{example_client_url}/jwks.json", + encryption_method: 'RS384' + ) + + invalid_hook_request = appointment_book_hook_request_hash + invalid_hook_request['fhirAuthorization']['token_type'] = 'Token' + + create_appointment_hook_request(body: invalid_hook_request, auth_header: "Bearer #{token}") + + result = run(test) + expect(result.result).to eq('fail') + expect(result.result_message).to eq("`fhirAuthorization` `token_type` field is not set to 'Bearer'") + end + + it 'passes if patient scope included but `patient` field is omitted' do + token = jwt_helper.build( + aud: appointment_book_url, + iss: example_client_url, + jku: "#{example_client_url}/jwks.json", + encryption_method: 'RS384' + ) + + patient_scope_hook_request = appointment_book_hook_request_hash + patient_scope_hook_request['fhirAuthorization']['scope'] += ' patient/Patient.read' + + create_appointment_hook_request(body: patient_scope_hook_request, auth_header: "Bearer #{token}") + + result = run(test) + expect(result.result).to eq('pass') + end +end diff --git a/spec/davinci_crd_test_kit/hook_request_required_fields_test_spec.rb b/spec/davinci_crd_test_kit/hook_request_required_fields_test_spec.rb new file mode 100644 index 0000000..bdc7a2a --- /dev/null +++ b/spec/davinci_crd_test_kit/hook_request_required_fields_test_spec.rb @@ -0,0 +1,147 @@ +require_relative '../../lib/davinci_crd_test_kit/client_tests/hook_request_required_fields_test' +require_relative '../../lib/davinci_crd_test_kit/jwt_helper' + +RSpec.describe DaVinciCRDTestKit::HookRequestRequiredFieldsTest do + let(:suite) { Inferno::Repositories::TestSuites.new.find('crd_client') } + let(:session_data_repo) { Inferno::Repositories::SessionData.new } + let(:test_session) { repo_create(:test_session, test_suite_id: 'crd_client') } + let(:jwt_helper) { Class.new(DaVinciCRDTestKit::JwtHelper) } + + let(:example_client_url) { 'https://cds.example.org' } + let(:base_url) { "#{Inferno::Application['base_url']}/custom/crd_client" } + let(:appointment_book_url) { "#{base_url}/cds-services/appointment-book-service" } + + let(:appointment_book_hook_request) do + File.read(File.join( + __dir__, '..', 'fixtures', 'appointment_book_hook_request.json' + )) + end + + let(:appointment_book_hook_request_hash) { JSON.parse(appointment_book_hook_request) } + + def run(runnable, inputs = {}) + test_run_params = { test_session_id: test_session.id }.merge(runnable.reference_hash) + test_run = Inferno::Repositories::TestRuns.new.create(test_run_params) + inputs.each do |name, value| + session_data_repo.save( + test_session_id: test_session.id, + name:, + value:, + type: runnable.config.input_type(name) + ) + end + Inferno::TestRunner.new(test_session:, test_run:).run(runnable) + end + + def create_appointment_hook_request(url: appointment_book_url, body: nil, status: 200, headers: nil, auth_header: nil) + headers ||= [ + { + type: 'request', + name: 'Authorization', + value: auth_header + } + ] + repo_create( + :request, + name: 'appointment_book', + direction: 'incoming', + url:, + test_session_id: test_session.id, + request_body: body.is_a?(Hash) ? body.to_json : body, + status:, + headers: + ) + end + + describe 'Appointment Book Hook Request Required Fields' do + let(:test) do + Class.new(DaVinciCRDTestKit::HookRequestRequiredFieldsTest) do + config( + options: { hook_path: '/cds-services/appointment-book-service', hook_name: 'appointment-book' }, + requests: { hook_request: { name: :appointment_book } } + ) + end + end + + it 'passes if valid hook request with all required fields included in request' do + allow(test).to receive(:suite).and_return(suite) + + token = jwt_helper.build( + aud: appointment_book_url, + iss: example_client_url, + jku: "#{example_client_url}/jwks.json", + encryption_method: 'RS384' + ) + + create_appointment_hook_request(body: appointment_book_hook_request, auth_header: "Bearer #{token}") + + result = run(test) + expect(result.result).to eq('pass') + end + + it 'skips if no appointment-book request can be found' do + allow(test).to receive(:suite).and_return(suite) + + result = run(test) + expect(result.result).to eq('skip') + expect(result.result_message).to eq('Request `appointment_book` was not made in a previous test as expected.') + end + + it 'fails if hook request body is not a valid json' do + allow(test).to receive(:suite).and_return(suite) + + token = jwt_helper.build( + aud: appointment_book_url, + iss: example_client_url, + jku: "#{example_client_url}/jwks.json", + encryption_method: 'RS384' + ) + + create_appointment_hook_request(body: 'request_body', auth_header: "Bearer #{token}") + + result = run(test) + expect(result.result).to eq('fail') + expect(result.result_message).to eq('Invalid JSON. ') + end + + it 'fails if hook request is missing a required field' do + allow(test).to receive(:suite).and_return(suite) + + token = jwt_helper.build( + aud: appointment_book_url, + iss: example_client_url, + jku: "#{example_client_url}/jwks.json", + encryption_method: 'RS384' + ) + + invalid_hook_request = appointment_book_hook_request_hash.except('context') + + create_appointment_hook_request(body: invalid_hook_request, auth_header: "Bearer #{token}") + + result = run(test) + expect(result.result).to eq('fail') + expect(result.result_message).to eq('Hook request did not contain required field: `context`') + end + + it 'fails if hook request contains fhirAuthorization field but not fhirServer field' do + allow(test).to receive(:suite).and_return(suite) + + token = jwt_helper.build( + aud: appointment_book_url, + iss: example_client_url, + jku: "#{example_client_url}/jwks.json", + encryption_method: 'RS384' + ) + + invalid_hook_request = appointment_book_hook_request_hash.except('fhirServer') + + create_appointment_hook_request(body: invalid_hook_request, auth_header: "Bearer #{token}") + + result = run(test) + expect(result.result).to eq('fail') + expect(result.result_message).to eq( + 'Missing `fhirServer` field: If `fhirAuthorization` is provided, this field is REQUIRED.' + ) + end + end +end diff --git a/spec/davinci_crd_test_kit/hook_request_valid_context_test_appointment_book_spec.rb b/spec/davinci_crd_test_kit/hook_request_valid_context_test_appointment_book_spec.rb new file mode 100644 index 0000000..61bf577 --- /dev/null +++ b/spec/davinci_crd_test_kit/hook_request_valid_context_test_appointment_book_spec.rb @@ -0,0 +1,410 @@ +require_relative '../../lib/davinci_crd_test_kit/client_tests/hook_request_valid_context_test' +require_relative '../../lib/davinci_crd_test_kit/jwt_helper' + +RSpec.describe DaVinciCRDTestKit::HookRequestValidContextTest do + let(:suite) { Inferno::Repositories::TestSuites.new.find('crd_client') } + let(:session_data_repo) { Inferno::Repositories::SessionData.new } + let(:results_repo) { Inferno::Repositories::Results.new } + let(:test_session) { repo_create(:test_session, test_suite_id: 'crd_client') } + let(:jwt_helper) { Class.new(DaVinciCRDTestKit::JwtHelper) } + + let(:example_client_url) { 'https://cds.example.org' } + let(:base_url) { "#{Inferno::Application['base_url']}/custom/crd_client" } + let(:appointment_book_url) { "#{base_url}/cds-services/appointment-book-service" } + let(:client_fhir_server) { 'https://example/r4' } + let(:client_access_token) { 'SAMPLE_TOKEN' } + + let(:appointment_book_hook_request) do + JSON.parse( + File.read(File.join( + __dir__, '..', 'fixtures', 'appointment_book_hook_request.json' + )) + ) + end + + let(:crd_patient) do + JSON.parse( + File.read(File.join( + __dir__, '..', 'fixtures', 'crd_patient_example.json' + )) + ) + end + + let(:crd_practitioner) do + JSON.parse( + File.read(File.join( + __dir__, '..', 'fixtures', 'crd_practitioner_example.json' + )) + ) + end + + let(:crd_encounter) do + JSON.parse( + File.read(File.join( + __dir__, '..', 'fixtures', 'crd_encounter_example.json' + )) + ) + end + + let(:operation_outcome_success) do + { + outcomes: [{ + issues: [] + }], + sessionId: 'b8cf5547-1dc7-4714-a797-dc2347b93fe2' + } + end + + let(:operation_outcome_failure) do + { + outcomes: [{ + issues: [{ + level: 'ERROR', + message: 'Resource does not conform to profile' + }] + }], + sessionId: 'b8cf5547-1dc7-4714-a797-dc2347b93fe2' + } + end + + let(:validator_url) { ENV.fetch('CRD_FHIR_RESOURCE_VALIDATOR_URL') } + + def run(runnable, inputs = {}) + test_run_params = { test_session_id: test_session.id }.merge(runnable.reference_hash) + test_run = Inferno::Repositories::TestRuns.new.create(test_run_params) + inputs.each do |name, value| + session_data_repo.save( + test_session_id: test_session.id, + name:, + value:, + type: runnable.config.input_type(name) || 'text' + ) + end + Inferno::TestRunner.new(test_session:, test_run:).run(runnable) + end + + def create_appointment_hook_request(url: appointment_book_url, body: nil, status: 200) + auth_token = jwt_helper.build( + aud: appointment_book_url, + iss: example_client_url, + jku: "#{example_client_url}/jwks.json", + encryption_method: 'RS384' + ) + + headers = [ + { + type: 'request', + name: 'Authorization', + value: "Bearer #{auth_token}" + } + ] + + repo_create( + :request, + name: 'appointment_book', + direction: 'incoming', + url:, + test_session_id: test_session.id, + request_body: body.is_a?(Hash) ? body.to_json : body, + status:, + headers: + ) + end + + def entity_result_message(runnable) + results_repo.current_results_for_test_session_and_runnables(test_session.id, [runnable]) + .first + .messages + .map(&:message) + .join(' ') + end + + describe 'Appointment Book Hook Valid Context' do + let(:test) do + Class.new(DaVinciCRDTestKit::HookRequestValidContextTest) do + fhir_resource_validator do + url ENV.fetch('CRD_FHIR_RESOURCE_VALIDATOR_URL') + + cli_context do + txServer nil + displayWarnings true + disableDefaultResourceFetcher true + end + + igs('hl7.fhir.us.davinci-crd', 'hl7.fhir.us.core') + end + config( + options: { hook_name: 'appointment-book' }, + requests: { + hook_request: { name: :appointment_book } + } + ) + end + end + + it 'passes if hook request `context` contains all required fields and fhir resources are valid' do + validation_request = stub_request(:post, "#{validator_url}/validate") + .to_return(status: 200, body: operation_outcome_success.to_json) + patient_resource_request = stub_request(:get, "#{client_fhir_server}/Patient/example") + .with( + headers: { Authorization: 'Bearer SAMPLE_TOKEN' } + ) + .to_return(status: 200, body: crd_patient.to_json) + practitioner_resource_request = stub_request(:get, "#{client_fhir_server}/Practitioner/example") + .with( + headers: { Authorization: 'Bearer SAMPLE_TOKEN' } + ) + .to_return(status: 200, body: crd_practitioner.to_json) + + create_appointment_hook_request(body: appointment_book_hook_request) + result = run(test, client_fhir_server:, client_access_token:) + expect(result.result).to eq('pass') + expect(validation_request).to have_been_made.times(4) + expect(patient_resource_request).to have_been_made + expect(practitioner_resource_request).to have_been_made + end + + it 'skips if no client fhir server url is found' do + create_appointment_hook_request(body: appointment_book_hook_request) + + result = run(test) + + expect(result.result).to eq('skip') + expect(result.result_message).to match("Input 'client_fhir_server' is nil, skipping test.") + end + + it 'fails if request body is not a valid json' do + create_appointment_hook_request(body: 'invalid_request') + + result = run(test, client_fhir_server:, client_access_token:) + + expect(result.result).to eq('fail') + expect(result.result_message).to eq('Invalid JSON. ') + end + + it 'fails if request body is does not contain the `context` field' do + invalid_hook_request = appointment_book_hook_request.except('context') + create_appointment_hook_request(body: invalid_hook_request) + + result = run(test, client_fhir_server:, client_access_token:) + + expect(result.result).to eq('fail') + expect(result.result_message).to eq('Hook request does not contain required `context` field') + end + + it 'fails if context does not contain all required fields' do + appointment_book_hook_request['context'].delete('patientId') + stub_request(:get, "#{client_fhir_server}/Practitioner/example") + .with( + headers: { Authorization: 'Bearer SAMPLE_TOKEN' } + ) + .to_return(status: 200, body: crd_practitioner.to_json) + create_appointment_hook_request(body: appointment_book_hook_request) + allow_any_instance_of(test).to receive(:resource_is_valid?).and_return(true) + result = run(test, client_fhir_server:, client_access_token:) + + expect(result.result).to eq('fail') + expect(entity_result_message(test)).to match(/context does not contain required field `patientId`/) + end + + it 'fails if resource type and id cannot be extracted from context `userId` field' do + appointment_book_hook_request['context']['userId'] = '/' + stub_request(:get, "#{client_fhir_server}/Patient/example") + .with( + headers: { Authorization: 'Bearer SAMPLE_TOKEN' } + ) + .to_return(status: 200, body: crd_patient.to_json) + + create_appointment_hook_request(body: appointment_book_hook_request) + allow_any_instance_of(test).to receive(:resource_is_valid?).and_return(true) + + result = run(test, client_fhir_server:, client_access_token:) + expect(result.result).to eq('fail') + expect(entity_result_message(test)).to match(/Invalid `userId` format/) + end + + it 'fails if context `userId` field resource type is not valid' do + appointment_book_hook_request['context']['userId'] = 'Observation/example' + stub_request(:get, "#{client_fhir_server}/Patient/example") + .with( + headers: { Authorization: 'Bearer SAMPLE_TOKEN' } + ) + .to_return(status: 200, body: crd_patient.to_json) + + create_appointment_hook_request(body: appointment_book_hook_request) + allow_any_instance_of(test).to receive(:resource_is_valid?).and_return(true) + + result = run(test, client_fhir_server:, client_access_token:) + + expect(result.result).to eq('fail') + expect(entity_result_message(test)).to match(/Unsupported resource type: `userId` type should be/) + end + + it 'fails if client fhir server returns non 200 response' do + validation_request = stub_request(:post, "#{validator_url}/validate") + .to_return(status: 200, body: operation_outcome_success.to_json) + patient_resource_request = stub_request(:get, "#{client_fhir_server}/Patient/example") + .with( + headers: { Authorization: 'Bearer SAMPLE_TOKEN' } + ) + .to_return(status: 200, body: crd_patient.to_json) + practitioner_resource_request = stub_request(:get, "#{client_fhir_server}/Practitioner/example") + .with( + headers: { Authorization: 'Bearer SAMPLE_TOKEN' } + ) + .to_return(status: 404) + + create_appointment_hook_request(body: appointment_book_hook_request) + + result = run(test, + client_fhir_server:, + client_access_token:) + + messages = Inferno::Repositories::Messages.new.messages_for_result(result.id) + + expect(result.result).to eq('fail') + expect(result.result_message).to match('Context is not valid') + expect(messages.length).to eq(1) + expect(messages.first.message).to match('expected 200, but received 404') + expect(validation_request).to have_been_made.at_least_once + expect(patient_resource_request).to have_been_made + expect(practitioner_resource_request).to have_been_made + end + + it 'fails if returned fhir resource fails validation' do + validation_request = stub_request(:post, "#{validator_url}/validate") + .to_return(status: 200, body: operation_outcome_failure.to_json) + practitioner_resource_request = stub_request(:get, "#{client_fhir_server}/Practitioner/example") + .with( + headers: { Authorization: 'Bearer SAMPLE_TOKEN' } + ) + .to_return(status: 200, body: crd_practitioner.to_json) + patient_resource_request = stub_request(:get, "#{client_fhir_server}/Patient/example") + .with( + headers: { Authorization: 'Bearer SAMPLE_TOKEN' } + ) + .to_return(status: 200, body: crd_patient.to_json) + + create_appointment_hook_request(body: appointment_book_hook_request) + + result = run(test, client_fhir_server:, client_access_token:) + + expect(result.result).to eq('fail') + expect(entity_result_message(test)).to match('Resource does not conform to profile') + expect(validation_request).to have_been_made.times(4) + expect(practitioner_resource_request).to have_been_made + expect(patient_resource_request).to have_been_made + end + + it 'passes if context contains optional `encounterId` field' do + validation_request = stub_request(:post, "#{validator_url}/validate") + .to_return(status: 200, body: operation_outcome_success.to_json) + patient_resource_request = stub_request(:get, "#{client_fhir_server}/Patient/example") + .with( + headers: { Authorization: 'Bearer SAMPLE_TOKEN' } + ) + .to_return(status: 200, body: crd_patient.to_json) + practitioner_resource_request = stub_request(:get, "#{client_fhir_server}/Practitioner/example") + .with( + headers: { Authorization: 'Bearer SAMPLE_TOKEN' } + ) + .to_return(status: 200, body: crd_practitioner.to_json) + encounter_resource_request = stub_request(:get, "#{client_fhir_server}/Encounter/example") + .with( + headers: { Authorization: 'Bearer SAMPLE_TOKEN' } + ) + .to_return(status: 200, body: crd_encounter.to_json) + + appointment_book_hook_request['context']['encounterId'] = 'example' + + create_appointment_hook_request(body: appointment_book_hook_request) + + result = run(test, + client_fhir_server:, + client_access_token:) + + expect(result.result).to eq('pass') + expect(validation_request).to have_been_made.times(5) + expect(patient_resource_request).to have_been_made + expect(practitioner_resource_request).to have_been_made + expect(encounter_resource_request).to have_been_made + end + + it 'fails if context `appointments` is not a bundle' do + allow_any_instance_of(test).to receive(:resource_is_valid?).and_return(true) + stub_request(:get, "#{client_fhir_server}/Practitioner/example") + .with( + headers: { Authorization: 'Bearer SAMPLE_TOKEN' } + ) + .to_return(status: 200, body: crd_practitioner.to_json) + + stub_request(:get, "#{client_fhir_server}/Patient/example") + .with( + headers: { Authorization: 'Bearer SAMPLE_TOKEN' } + ) + .to_return(status: 200, body: crd_patient.to_json) + + appointment_book_hook_request['context']['appointments'] = crd_patient + + create_appointment_hook_request(body: appointment_book_hook_request) + + result = run(test, client_fhir_server:, client_access_token:) + expect(result.result).to eq('fail') + expect(entity_result_message(test)).to match(/Wrong context resource type: Expected `Bundle`, got `Patient`/) + end + + it 'fails if there are no appointment resources in context `appointments` field' do + allow_any_instance_of(test).to receive(:resource_is_valid?).and_return(true) + stub_request(:get, "#{client_fhir_server}/Practitioner/example") + .with( + headers: { Authorization: 'Bearer SAMPLE_TOKEN' } + ) + .to_return(status: 200, body: crd_practitioner.to_json) + + stub_request(:get, "#{client_fhir_server}/Patient/example") + .with( + headers: { Authorization: 'Bearer SAMPLE_TOKEN' } + ) + .to_return(status: 200, body: crd_patient.to_json) + + appointment_book_hook_request['context']['appointments']['entry'].each do |entry| + entry['resource'] = crd_patient + end + + create_appointment_hook_request(body: appointment_book_hook_request) + + result = run(test, client_fhir_server:, client_access_token:) + + expect(result.result).to eq('fail') + expect(entity_result_message(test)).to match( + /`appointments` bundle must contain at least one of the expected resource types:/ + ) + end + + it 'fails if all appointments in context `appointments` field not in proposed state' do + allow_any_instance_of(test).to receive(:resource_is_valid?).and_return(true) + stub_request(:get, "#{client_fhir_server}/Practitioner/example") + .with( + headers: { Authorization: 'Bearer SAMPLE_TOKEN' } + ) + .to_return(status: 200, body: crd_practitioner.to_json) + + stub_request(:get, "#{client_fhir_server}/Patient/example") + .with( + headers: { Authorization: 'Bearer SAMPLE_TOKEN' } + ) + .to_return(status: 200, body: crd_patient.to_json) + + appointment_book_hook_request['context']['appointments']['entry'][0]['resource']['status'] = 'confirmed' + + create_appointment_hook_request(body: appointment_book_hook_request) + + result = run(test, client_fhir_server:, client_access_token:) + + expect(result.result).to eq('fail') + expect(entity_result_message(test)).to match( + /All Appointment resources in `appointments` bundle must have a `proposed` status./ + ) + end + end +end diff --git a/spec/davinci_crd_test_kit/hook_request_valid_context_test_encounter_hook_spec.rb b/spec/davinci_crd_test_kit/hook_request_valid_context_test_encounter_hook_spec.rb new file mode 100644 index 0000000..a1c518e --- /dev/null +++ b/spec/davinci_crd_test_kit/hook_request_valid_context_test_encounter_hook_spec.rb @@ -0,0 +1,347 @@ +require_relative '../../lib/davinci_crd_test_kit/client_tests/hook_request_valid_context_test' +require_relative '../../lib/davinci_crd_test_kit/jwt_helper' + +RSpec.describe DaVinciCRDTestKit::HookRequestValidContextTest do + let(:suite) { Inferno::Repositories::TestSuites.new.find('crd_client') } + let(:session_data_repo) { Inferno::Repositories::SessionData.new } + let(:results_repo) { Inferno::Repositories::Results.new } + let(:test_session) { repo_create(:test_session, test_suite_id: 'crd_client') } + let(:klass) { Class.new(DaVinciCRDTestKit::JwtHelper) } + + let(:example_client_url) { 'https://cds.example.org' } + let(:base_url) { "#{Inferno::Application['base_url']}/custom/crd_client" } + let(:encounter_start_url) { "#{base_url}/cds-services/encounter-start-service" } + let(:client_fhir_server) { 'https://example/r4' } + let(:client_bearer_token) { 'SAMPLE_TOKEN' } + + let(:encounter_start_hook_request) do + JSON.parse( + File.read(File.join( + __dir__, '..', 'fixtures', 'encounter_start_hook_request.json' + )) + ) + end + + let(:crd_patient) do + JSON.parse( + File.read(File.join( + __dir__, '..', 'fixtures', 'crd_patient_example.json' + )) + ) + end + + let(:crd_practitioner) do + JSON.parse( + File.read(File.join( + __dir__, '..', 'fixtures', 'crd_practitioner_example.json' + )) + ) + end + + let(:crd_encounter) do + JSON.parse( + File.read(File.join( + __dir__, '..', 'fixtures', 'crd_encounter_example.json' + )) + ) + end + + let(:operation_outcome_success) do + { + outcomes: [{ + issues: [] + }], + sessionId: 'b8cf5547-1dc7-4714-a797-dc2347b93fe2' + } + end + + let(:operation_outcome_failure) do + { + outcomes: [{ + issues: [{ + level: 'ERROR', + message: 'Resource does not conform to profile' + }] + }], + sessionId: 'b8cf5547-1dc7-4714-a797-dc2347b93fe2' + } + end + + let(:validator_url) { ENV.fetch('CRD_FHIR_RESOURCE_VALIDATOR_URL') } + + def run(runnable, inputs = {}) + test_run_params = { test_session_id: test_session.id }.merge(runnable.reference_hash) + test_run = Inferno::Repositories::TestRuns.new.create(test_run_params) + inputs.each do |name, value| + session_data_repo.save( + test_session_id: test_session.id, + name:, + value:, + type: runnable.config.input_type(name) + ) + end + Inferno::TestRunner.new(test_session:, test_run:).run(runnable) + end + + def create_encounter_start_request(url: encounter_start_url, body: nil, status: 200) + auth_token = klass.build( + aud: encounter_start_url, + iss: example_client_url, + jku: "#{example_client_url}/jwks.json", + encryption_method: 'RS384' + ) + + headers = [ + { + type: 'request', + name: 'Authorization', + value: "Bearer #{auth_token}" + } + ] + + repo_create( + :request, + name: 'encounter_start', + direction: 'incoming', + url:, + test_session_id: test_session.id, + request_body: body.is_a?(Hash) ? body.to_json : body, + status:, + headers: + ) + end + + def entity_result_message(runnable) + results_repo.current_results_for_test_session_and_runnables(test_session.id, [runnable]) + .first + .messages + .map(&:message) + .join(' ') + end + + describe 'Encounter Start Hook Valid Context' do + let(:test) do + Class.new(DaVinciCRDTestKit::HookRequestValidContextTest) do + fhir_resource_validator do + url ENV.fetch('CRD_FHIR_RESOURCE_VALIDATOR_URL') + + cli_context do + txServer nil + displayWarnings true + disableDefaultResourceFetcher true + end + + igs('hl7.fhir.us.davinci-crd', 'hl7.fhir.us.core') + end + config( + options: { hook_name: 'encounter-start' }, + requests: { + hook_request: { name: :encounter_start } + } + ) + end + end + + it 'passes if hook request `context` contains all required fields and fhir resources are valid' do + validation_request = stub_request(:post, "#{validator_url}/validate") + .to_return(status: 200, body: operation_outcome_success.to_json) + patient_resource_request = stub_request(:get, "#{client_fhir_server}/Patient/example") + .with( + headers: { Authorization: 'Bearer SAMPLE_TOKEN' } + ) + .to_return(status: 200, body: crd_patient.to_json) + practitioner_resource_request = stub_request(:get, "#{client_fhir_server}/Practitioner/example") + .with( + headers: { Authorization: 'Bearer SAMPLE_TOKEN' } + ) + .to_return(status: 200, body: crd_practitioner.to_json) + encounter_resource_request = stub_request(:get, "#{client_fhir_server}/Encounter/example") + .with( + headers: { Authorization: 'Bearer SAMPLE_TOKEN' } + ) + .to_return(status: 200, body: crd_encounter.to_json) + create_encounter_start_request(body: encounter_start_hook_request) + + result = run(test, + client_fhir_server:, + client_access_token: client_bearer_token) + + expect(result.result).to eq('pass') + expect(validation_request).to have_been_made.times(3) + expect(patient_resource_request).to have_been_made + expect(practitioner_resource_request).to have_been_made + expect(encounter_resource_request).to have_been_made + end + + it 'skips if no client fhir server url is found' do + create_encounter_start_request(body: encounter_start_hook_request) + + result = run(test) + + expect(result.result).to eq('skip') + expect(result.result_message).to eq( + "Input 'client_fhir_server' is nil, skipping test." + ) + end + + it 'fails if request body is not a valid json' do + create_encounter_start_request(body: 'invalid_request') + + result = run(test, + client_fhir_server:, + client_access_token: client_bearer_token) + + expect(result.result).to eq('fail') + expect(result.result_message).to eq('Invalid JSON. ') + end + + it 'fails if request body is does not contain the `context` field' do + invalid_hook_request = encounter_start_hook_request.except('context') + create_encounter_start_request(body: invalid_hook_request) + + result = run(test, + client_fhir_server:, + client_access_token: client_bearer_token) + + expect(result.result).to eq('fail') + expect(result.result_message).to eq('Hook request does not contain required `context` field') + end + + it 'fails if context does not contain all required fields' do + allow_any_instance_of(test).to receive(:resource_is_valid?).and_return(true) + stub_request(:get, "#{client_fhir_server}/Patient/example") + .with( + headers: { Authorization: 'Bearer SAMPLE_TOKEN' } + ) + .to_return(status: 200, body: crd_patient.to_json) + stub_request(:get, "#{client_fhir_server}/Practitioner/example") + .with( + headers: { Authorization: 'Bearer SAMPLE_TOKEN' } + ) + .to_return(status: 200, body: crd_practitioner.to_json) + encounter_start_hook_request['context'].delete('encounterId') + create_encounter_start_request(body: encounter_start_hook_request) + + result = run(test, + client_fhir_server:, + client_access_token: client_bearer_token) + + expect(result.result).to eq('fail') + expect(entity_result_message(test)).to match( + /encounter-start request context does not contain required field `encounterId`/ + ) + end + + it 'fails if resource type and id cannot be extracted from context `userId` field' do + allow_any_instance_of(test).to receive(:resource_is_valid?).and_return(true) + stub_request(:get, "#{client_fhir_server}/Patient/example") + .with( + headers: { Authorization: 'Bearer SAMPLE_TOKEN' } + ) + .to_return(status: 200, body: crd_patient.to_json) + stub_request(:get, "#{client_fhir_server}/Encounter/example") + .with( + headers: { Authorization: 'Bearer SAMPLE_TOKEN' } + ) + .to_return(status: 200, body: crd_encounter.to_json) + + encounter_start_hook_request['context']['userId'] = '/' + create_encounter_start_request(body: encounter_start_hook_request) + + result = run(test, + client_fhir_server:, + client_access_token: client_bearer_token) + + expect(result.result).to eq('fail') + expect(entity_result_message(test)).to match(/Invalid `userId` format./) + end + + it 'fails if context `userId` field resource type is not valid' do + allow_any_instance_of(test).to receive(:resource_is_valid?).and_return(true) + stub_request(:get, "#{client_fhir_server}/Patient/example") + .with( + headers: { Authorization: 'Bearer SAMPLE_TOKEN' } + ) + .to_return(status: 200, body: crd_patient.to_json) + stub_request(:get, "#{client_fhir_server}/Encounter/example") + .with( + headers: { Authorization: 'Bearer SAMPLE_TOKEN' } + ) + .to_return(status: 200, body: crd_encounter.to_json) + + encounter_start_hook_request['context']['userId'] = 'Observation/example' + create_encounter_start_request(body: encounter_start_hook_request) + + result = run(test, + client_fhir_server:, + client_access_token: client_bearer_token) + + expect(result.result).to eq('fail') + expect(entity_result_message(test)).to match(/Unsupported resource type: `userId` type should be/) + end + + it 'fails if client fhir server returns non 200 response' do + allow_any_instance_of(test).to receive(:resource_is_valid?).and_return(true) + patient_resource_request = stub_request(:get, "#{client_fhir_server}/Patient/example") + .with( + headers: { Authorization: 'Bearer SAMPLE_TOKEN' } + ) + .to_return(status: 200, body: crd_patient.to_json) + practitioner_resource_request = stub_request(:get, "#{client_fhir_server}/Practitioner/example") + .with( + headers: { Authorization: 'Bearer SAMPLE_TOKEN' } + ) + .to_return(status: 404) + encounter_resource_request = stub_request(:get, "#{client_fhir_server}/Encounter/example") + .with( + headers: { Authorization: 'Bearer SAMPLE_TOKEN' } + ) + .to_return(status: 200, body: crd_encounter.to_json) + + create_encounter_start_request(body: encounter_start_hook_request) + + result = run(test, + client_fhir_server:, + client_access_token: client_bearer_token) + + expect(result.result).to eq('fail') + expect(entity_result_message(test)).to match(/Unexpected response status: expected 200, but received 404/) + expect(patient_resource_request).to have_been_made + expect(practitioner_resource_request).to have_been_made + expect(encounter_resource_request).to have_been_made + end + + it 'fails if returned fhir resource fails validation' do + validation_request = stub_request(:post, "#{validator_url}/validate") + .to_return(status: 200, body: operation_outcome_failure.to_json) + patient_resource_request = stub_request(:get, "#{client_fhir_server}/Patient/example") + .with( + headers: { Authorization: 'Bearer SAMPLE_TOKEN' } + ) + .to_return(status: 200, body: crd_patient.to_json) + encounter_resource_request = stub_request(:get, "#{client_fhir_server}/Encounter/example") + .with( + headers: { Authorization: 'Bearer SAMPLE_TOKEN' } + ) + .to_return(status: 200, body: crd_encounter.to_json) + practitioner_resource_request = stub_request(:get, "#{client_fhir_server}/Practitioner/example") + .with( + headers: { Authorization: 'Bearer SAMPLE_TOKEN' } + ) + .to_return(status: 200, body: crd_practitioner.to_json) + + create_encounter_start_request(body: encounter_start_hook_request) + + result = run(test, + client_fhir_server:, + client_access_token: client_bearer_token) + + expect(result.result).to eq('fail') + expect(entity_result_message(test)).to match(/Resource does not conform to/) + expect(validation_request).to have_been_made.times(3) + expect(practitioner_resource_request).to have_been_made + expect(patient_resource_request).to have_been_made + expect(encounter_resource_request).to have_been_made + end + end +end diff --git a/spec/davinci_crd_test_kit/hook_request_valid_context_test_order_dispatch_spec.rb b/spec/davinci_crd_test_kit/hook_request_valid_context_test_order_dispatch_spec.rb new file mode 100644 index 0000000..c81aa02 --- /dev/null +++ b/spec/davinci_crd_test_kit/hook_request_valid_context_test_order_dispatch_spec.rb @@ -0,0 +1,397 @@ +require_relative '../../lib/davinci_crd_test_kit/client_tests/hook_request_valid_context_test' +require_relative '../../lib/davinci_crd_test_kit/jwt_helper' + +RSpec.describe DaVinciCRDTestKit::HookRequestValidContextTest do + let(:suite) { Inferno::Repositories::TestSuites.new.find('crd_client') } + let(:session_data_repo) { Inferno::Repositories::SessionData.new } + let(:results_repo) { Inferno::Repositories::Results.new } + let(:test_session) { repo_create(:test_session, test_suite_id: 'crd_client') } + let(:klass) { Class.new(DaVinciCRDTestKit::JwtHelper) } + + let(:example_client_url) { 'https://cds.example.org' } + let(:base_url) { "#{Inferno::Application['base_url']}/custom/crd_client" } + let(:order_dispatch_url) { "#{base_url}/cds-services/order-dispatch-service" } + let(:client_fhir_server) { 'https://example/r4' } + let(:client_bearer_token) { 'SAMPLE_TOKEN' } + let(:task_profile) { 'http://hl7.org/fhir/StructureDefinition/Task' } + + let(:order_dispatch_hook_request) do + JSON.parse( + File.read(File.join( + __dir__, '..', 'fixtures', 'order_dispatch_hook_request.json' + )) + ) + end + + let(:crd_patient) do + JSON.parse( + File.read(File.join( + __dir__, '..', 'fixtures', 'crd_patient_example.json' + )) + ) + end + + let(:crd_practitioner) do + JSON.parse( + File.read(File.join( + __dir__, '..', 'fixtures', 'crd_practitioner_example.json' + )) + ) + end + + let(:crd_service_request) do + JSON.parse( + File.read(File.join( + __dir__, '..', 'fixtures', 'crd_service_request_example.json' + )) + ) + end + + let(:operation_outcome_success) do + { + outcomes: [{ + issues: [] + }], + sessionId: 'b8cf5547-1dc7-4714-a797-dc2347b93fe2' + } + end + + let(:operation_outcome_failure) do + { + outcomes: [{ + issues: [{ + level: 'ERROR', + message: 'Resource does not conform to profile' + }] + }], + sessionId: 'b8cf5547-1dc7-4714-a797-dc2347b93fe2' + } + end + + let(:validator_url) { ENV.fetch('CRD_FHIR_RESOURCE_VALIDATOR_URL') } + + def run(runnable, inputs = {}) + test_run_params = { test_session_id: test_session.id }.merge(runnable.reference_hash) + test_run = Inferno::Repositories::TestRuns.new.create(test_run_params) + inputs.each do |name, value| + session_data_repo.save( + test_session_id: test_session.id, + name:, + value:, + type: runnable.config.input_type(name) + ) + end + Inferno::TestRunner.new(test_session:, test_run:).run(runnable) + end + + def create_order_dispatch_hook_request(url: order_dispatch_url, body: nil, status: 200) + auth_token = klass.build( + aud: order_dispatch_url, + iss: example_client_url, + jku: "#{example_client_url}/jwks.json", + encryption_method: 'RS384' + ) + + headers = [ + { + type: 'request', + name: 'Authorization', + value: "Bearer #{auth_token}" + } + ] + + repo_create( + :request, + name: 'order_dispatch', + direction: 'incoming', + url:, + test_session_id: test_session.id, + request_body: body.is_a?(Hash) ? body.to_json : body, + status:, + headers: + ) + end + + def entity_result_message(runnable) + results_repo.current_results_for_test_session_and_runnables(test_session.id, [runnable]) + .first + .messages + .map(&:message) + .join(' ') + end + + describe 'Order Dispatch Hook Valid Context' do + let(:test) do + Class.new(DaVinciCRDTestKit::HookRequestValidContextTest) do + fhir_resource_validator do + url ENV.fetch('CRD_FHIR_RESOURCE_VALIDATOR_URL') + + cli_context do + txServer nil + displayWarnings true + disableDefaultResourceFetcher true + end + + igs('hl7.fhir.us.davinci-crd', 'hl7.fhir.us.core') + end + config( + options: { hook_name: 'order-dispatch' }, + requests: { + hook_request: { name: :order_dispatch } + } + ) + end + end + + it 'passes if hook request `context` contains all required fields and fhir resources are valid' do + validation_request = stub_request(:post, "#{validator_url}/validate") + .to_return(status: 200, body: operation_outcome_success.to_json) + patient_resource_request = stub_request(:get, "#{client_fhir_server}/Patient/example") + .with( + headers: { Authorization: 'Bearer SAMPLE_TOKEN' } + ) + .to_return(status: 200, body: crd_patient.to_json) + practitioner_resource_request = stub_request(:get, "#{client_fhir_server}/Practitioner/example") + .with( + headers: { Authorization: 'Bearer SAMPLE_TOKEN' } + ) + .to_return(status: 200, body: crd_practitioner.to_json) + service_request_resource_request = stub_request(:get, "#{client_fhir_server}/ServiceRequest/example") + .with( + headers: { Authorization: 'Bearer SAMPLE_TOKEN' } + ) + .to_return(status: 200, body: crd_service_request.to_json) + + create_order_dispatch_hook_request(body: order_dispatch_hook_request) + + result = run(test, + client_fhir_server:, + client_access_token: client_bearer_token) + + expect(result.result).to eq('pass') + expect(validation_request).to have_been_made.times(4) + expect(patient_resource_request).to have_been_made + expect(practitioner_resource_request).to have_been_made + expect(service_request_resource_request).to have_been_made + end + + it 'skips if no client fhir server url is found' do + create_order_dispatch_hook_request(body: order_dispatch_hook_request) + + result = run(test) + + expect(result.result).to eq('skip') + expect(result.result_message).to eq( + "Input 'client_fhir_server' is nil, skipping test." + ) + end + + it 'fails if request body is not a valid json' do + create_order_dispatch_hook_request(body: 'invalid_request') + + result = run(test, + client_fhir_server:, + client_access_token: client_bearer_token) + + expect(result.result).to eq('fail') + expect(result.result_message).to eq('Invalid JSON. ') + end + + it 'fails if request body is does not contain the `context` field' do + invalid_hook_request = order_dispatch_hook_request.except('context') + create_order_dispatch_hook_request(body: invalid_hook_request) + + result = run(test, + client_fhir_server:, + client_access_token: client_bearer_token) + + expect(result.result).to eq('fail') + expect(result.result_message).to eq('Hook request does not contain required `context` field') + end + + it 'fails if context does not contain all required fields' do + allow_any_instance_of(test).to receive(:resource_is_valid?).and_return(true) + stub_request(:get, "#{client_fhir_server}/ServiceRequest/example") + .with( + headers: { Authorization: 'Bearer SAMPLE_TOKEN' } + ) + .to_return(status: 200, body: crd_service_request.to_json) + stub_request(:get, "#{client_fhir_server}/Practitioner/example") + .with( + headers: { Authorization: 'Bearer SAMPLE_TOKEN' } + ) + .to_return(status: 200, body: crd_practitioner.to_json) + + order_dispatch_hook_request['context'].delete('patientId') + create_order_dispatch_hook_request(body: order_dispatch_hook_request) + + result = run(test, + client_fhir_server:, + client_access_token: client_bearer_token) + + expect(result.result).to eq('fail') + expect(entity_result_message(test)).to match( + /order-dispatch request context does not contain required field `patientId`/ + ) + end + + it 'fails if resource type and id cannot be extracted from context `performer` field' do + allow_any_instance_of(test).to receive(:resource_is_valid?).and_return(true) + stub_request(:get, "#{client_fhir_server}/Patient/example") + .with( + headers: { Authorization: 'Bearer SAMPLE_TOKEN' } + ) + .to_return(status: 200, body: crd_patient.to_json) + stub_request(:get, "#{client_fhir_server}/ServiceRequest/example") + .with( + headers: { Authorization: 'Bearer SAMPLE_TOKEN' } + ) + .to_return(status: 200, body: crd_service_request.to_json) + + order_dispatch_hook_request['context']['performer'] = '/' + create_order_dispatch_hook_request(body: order_dispatch_hook_request) + + result = run(test, + client_fhir_server:, + client_access_token: client_bearer_token) + + expect(result.result).to eq('fail') + expect(entity_result_message(test)).to match(/Invalid `performer` format./) + end + + it 'fails if client fhir server returns non 200 response' do + allow_any_instance_of(test).to receive(:resource_is_valid?).and_return(true) + patient_resource_request = stub_request(:get, "#{client_fhir_server}/Patient/example") + .with( + headers: { Authorization: 'Bearer SAMPLE_TOKEN' } + ) + .to_return(status: 404) + service_request_resource_request = stub_request(:get, "#{client_fhir_server}/ServiceRequest/example") + .with( + headers: { Authorization: 'Bearer SAMPLE_TOKEN' } + ) + .to_return(status: 200, body: crd_service_request.to_json) + practitioner_resource_request = stub_request(:get, "#{client_fhir_server}/Practitioner/example") + .with( + headers: { Authorization: 'Bearer SAMPLE_TOKEN' } + ) + .to_return(status: 200, body: crd_practitioner.to_json) + + create_order_dispatch_hook_request(body: order_dispatch_hook_request) + + result = run(test, + client_fhir_server:, + client_access_token: client_bearer_token) + + expect(result.result).to eq('fail') + expect(entity_result_message(test)).to match(/Unexpected response status: expected 200, but received 404/) + expect(patient_resource_request).to have_been_made + expect(service_request_resource_request).to have_been_made + expect(practitioner_resource_request).to have_been_made + end + + it 'fails if returned fhir resource fails validation' do + validation_request = stub_request(:post, "#{validator_url}/validate") + .to_return(status: 200, body: operation_outcome_failure.to_json) + patient_resource_request = stub_request(:get, "#{client_fhir_server}/Patient/example") + .with( + headers: { Authorization: 'Bearer SAMPLE_TOKEN' } + ) + .to_return(status: 200, body: crd_patient.to_json) + practitioner_resource_request = stub_request(:get, "#{client_fhir_server}/Practitioner/example") + .with( + headers: { Authorization: 'Bearer SAMPLE_TOKEN' } + ) + .to_return(status: 200, body: crd_practitioner.to_json) + service_request_resource_request = stub_request(:get, "#{client_fhir_server}/ServiceRequest/example") + .with( + headers: { Authorization: 'Bearer SAMPLE_TOKEN' } + ) + .to_return(status: 200, body: crd_service_request.to_json) + + create_order_dispatch_hook_request(body: order_dispatch_hook_request) + + result = run(test, + client_fhir_server:, + client_access_token: client_bearer_token) + + expect(result.result).to eq('fail') + expect(entity_result_message(test)).to match(/Resource does not conform to/) + expect(validation_request).to have_been_made.times(4) + expect(patient_resource_request).to have_been_made + expect(service_request_resource_request).to have_been_made + expect(practitioner_resource_request).to have_been_made + end + + it 'fails if context `task` is not a task resource' do + patient_resource_request = stub_request(:get, "#{client_fhir_server}/Patient/example") + .with( + headers: { Authorization: 'Bearer SAMPLE_TOKEN' } + ) + .to_return(status: 200, body: crd_patient.to_json) + practitioner_resource_request = stub_request(:get, "#{client_fhir_server}/Practitioner/example") + .with( + headers: { Authorization: 'Bearer SAMPLE_TOKEN' } + ) + .to_return(status: 200, body: crd_practitioner.to_json) + service_request_resource_request = stub_request(:get, "#{client_fhir_server}/ServiceRequest/example") + .with( + headers: { Authorization: 'Bearer SAMPLE_TOKEN' } + ) + .to_return(status: 200, body: crd_service_request.to_json) + allow_any_instance_of(test).to receive(:resource_is_valid?).and_return(true) + + order_dispatch_hook_request['context']['task'] = crd_patient + + create_order_dispatch_hook_request(body: order_dispatch_hook_request) + + result = run(test, + client_fhir_server:, + client_access_token: client_bearer_token) + + expect(result.result).to eq('fail') + expect(entity_result_message(test)).to match(/Field `task` must be a `Task`. Got `Patient`/) + expect(patient_resource_request).to have_been_made + expect(practitioner_resource_request).to have_been_made + expect(service_request_resource_request).to have_been_made + end + + it 'fails if context `task` is not a valid resource' do + validation_request = stub_request(:post, "#{validator_url}/validate") + .with { |request| request.body.exclude?(task_profile) } + .to_return(status: 200, body: operation_outcome_success.to_json) + task_validation_request = stub_request(:post, "#{validator_url}/validate") + .with { |request| request.body.include?(task_profile) } + .to_return(status: 200, body: operation_outcome_failure.to_json) + patient_resource_request = stub_request(:get, "#{client_fhir_server}/Patient/example") + .with( + headers: { Authorization: 'Bearer SAMPLE_TOKEN' } + ) + .to_return(status: 200, body: crd_patient.to_json) + practitioner_resource_request = stub_request(:get, "#{client_fhir_server}/Practitioner/example") + .with( + headers: { Authorization: 'Bearer SAMPLE_TOKEN' } + ) + .to_return(status: 200, body: crd_practitioner.to_json) + service_request_resource_request = stub_request(:get, "#{client_fhir_server}/ServiceRequest/example") + .with( + headers: { Authorization: 'Bearer SAMPLE_TOKEN' } + ) + .to_return(status: 200, body: crd_service_request.to_json) + + create_order_dispatch_hook_request(body: order_dispatch_hook_request) + + result = run(test, + client_fhir_server:, + client_access_token: client_bearer_token) + + expect(result.result).to eq('fail') + expect(entity_result_message(test)).to match(/Resource does not conform to/) + expect(validation_request).to have_been_made.times(3) + expect(task_validation_request).to have_been_made + expect(patient_resource_request).to have_been_made + expect(practitioner_resource_request).to have_been_made + expect(service_request_resource_request).to have_been_made + end + end +end diff --git a/spec/davinci_crd_test_kit/hook_request_valid_context_test_order_select_spec.rb b/spec/davinci_crd_test_kit/hook_request_valid_context_test_order_select_spec.rb new file mode 100644 index 0000000..c5e7e78 --- /dev/null +++ b/spec/davinci_crd_test_kit/hook_request_valid_context_test_order_select_spec.rb @@ -0,0 +1,479 @@ +require_relative '../../lib/davinci_crd_test_kit/client_tests/hook_request_valid_context_test' +require_relative '../../lib/davinci_crd_test_kit/jwt_helper' + +RSpec.describe DaVinciCRDTestKit::HookRequestValidContextTest do + let(:suite) { Inferno::Repositories::TestSuites.new.find('crd_client') } + let(:session_data_repo) { Inferno::Repositories::SessionData.new } + let(:results_repo) { Inferno::Repositories::Results.new } + let(:test_session) { repo_create(:test_session, test_suite_id: 'crd_client') } + let(:klass) { Class.new(DaVinciCRDTestKit::JwtHelper) } + + let(:example_client_url) { 'https://cds.example.org' } + let(:base_url) { "#{Inferno::Application['base_url']}/custom/crd_client" } + let(:order_select_url) { "#{base_url}/cds-services/order-select-service" } + let(:client_fhir_server) { 'https://example/r4' } + let(:client_bearer_token) { 'SAMPLE_TOKEN' } + let(:nutrition_order_profile) { 'http://hl7.org/fhir/us/davinci-crd/StructureDefinition/profile-nutritionorder' } + + let(:order_select_hook_request) do + JSON.parse( + File.read(File.join( + __dir__, '..', 'fixtures', 'order_select_hook_request.json' + )) + ) + end + + let(:crd_patient) do + JSON.parse( + File.read(File.join( + __dir__, '..', 'fixtures', 'crd_patient_example.json' + )) + ) + end + + let(:crd_practitioner) do + JSON.parse( + File.read(File.join( + __dir__, '..', 'fixtures', 'crd_practitioner_example.json' + )) + ) + end + + let(:crd_encounter) do + JSON.parse( + File.read(File.join( + __dir__, '..', 'fixtures', 'crd_encounter_example.json' + )) + ) + end + + let(:crd_nutrition_order) do + FHIR.from_contents(order_select_hook_request['context']['draftOrders']['entry'][0]['resource'].to_json) + end + + let(:operation_outcome_success) do + { + outcomes: [{ + issues: [] + }], + sessionId: 'b8cf5547-1dc7-4714-a797-dc2347b93fe2' + } + end + + let(:operation_outcome_failure) do + { + outcomes: [{ + issues: [{ + level: 'ERROR', + message: 'Resource does not conform to profile' + }] + }], + sessionId: 'b8cf5547-1dc7-4714-a797-dc2347b93fe2' + } + end + + let(:validator_url) { ENV.fetch('CRD_FHIR_RESOURCE_VALIDATOR_URL') } + + def run(runnable, inputs = {}) + test_run_params = { test_session_id: test_session.id }.merge(runnable.reference_hash) + test_run = Inferno::Repositories::TestRuns.new.create(test_run_params) + inputs.each do |name, value| + session_data_repo.save( + test_session_id: test_session.id, + name:, + value:, + type: runnable.config.input_type(name) + ) + end + Inferno::TestRunner.new(test_session:, test_run:).run(runnable) + end + + def create_order_select_hook_request(url: order_select_url, body: nil, status: 200) + auth_token = klass.build( + aud: order_select_url, + iss: example_client_url, + jku: "#{example_client_url}/jwks.json", + encryption_method: 'RS384' + ) + + headers = [ + { + type: 'request', + name: 'Authorization', + value: "Bearer #{auth_token}" + } + ] + + repo_create( + :request, + name: 'order_select', + direction: 'incoming', + url:, + test_session_id: test_session.id, + request_body: body.is_a?(Hash) ? body.to_json : body, + status:, + headers: + ) + end + + def entity_result_message(runnable) + results_repo.current_results_for_test_session_and_runnables(test_session.id, [runnable]) + .first + .messages + .map(&:message) + .join(' ') + end + + describe 'Order Select Hook Valid Context' do + let(:test) do + Class.new(DaVinciCRDTestKit::HookRequestValidContextTest) do + fhir_resource_validator do + url ENV.fetch('CRD_FHIR_RESOURCE_VALIDATOR_URL') + + cli_context do + txServer nil + displayWarnings true + disableDefaultResourceFetcher true + end + + igs('hl7.fhir.us.davinci-crd', 'hl7.fhir.us.core') + end + config( + options: { hook_name: 'order-select' }, + requests: { + hook_request: { name: :order_select } + } + ) + end + end + + it 'passes if hook request `context` contains all required fields and fhir resources are valid' do + validation_request = stub_request(:post, "#{validator_url}/validate") + .to_return(status: 200, body: operation_outcome_success.to_json) + patient_resource_request = stub_request(:get, "#{client_fhir_server}/Patient/example") + .with( + headers: { Authorization: 'Bearer SAMPLE_TOKEN' } + ) + .to_return(status: 200, body: crd_patient.to_json) + practitioner_resource_request = stub_request(:get, "#{client_fhir_server}/Practitioner/example") + .with( + headers: { Authorization: 'Bearer SAMPLE_TOKEN' } + ) + .to_return(status: 200, body: crd_practitioner.to_json) + + create_order_select_hook_request(body: order_select_hook_request) + + result = run(test, + client_fhir_server:, + client_access_token: client_bearer_token) + + expect(result.result).to eq('pass') + expect(validation_request).to have_been_made.times(4) + expect(patient_resource_request).to have_been_made + expect(practitioner_resource_request).to have_been_made + end + + it 'skips if no client fhir server url is found' do + create_order_select_hook_request(body: order_select_hook_request) + + result = run(test) + + expect(result.result).to eq('skip') + expect(result.result_message).to eq( + "Input 'client_fhir_server' is nil, skipping test." + ) + end + + it 'fails if request body is not a valid json' do + create_order_select_hook_request(body: 'invalid_request') + + result = run(test, + client_fhir_server:, + client_access_token: client_bearer_token) + + expect(result.result).to eq('fail') + expect(result.result_message).to eq('Invalid JSON. ') + end + + it 'fails if request body is does not contain the `context` field' do + invalid_hook_request = order_select_hook_request.except('context') + create_order_select_hook_request(body: invalid_hook_request) + + result = run(test, + client_fhir_server:, + client_access_token: client_bearer_token) + + expect(result.result).to eq('fail') + expect(result.result_message).to eq('Hook request does not contain required `context` field') + end + + it 'fails if context does not contain all required fields' do + allow_any_instance_of(test).to receive(:resource_is_valid?).and_return(true) + stub_request(:get, "#{client_fhir_server}/Practitioner/example") + .with( + headers: { Authorization: 'Bearer SAMPLE_TOKEN' } + ) + .to_return(status: 200, body: crd_practitioner.to_json) + order_select_hook_request['context'].delete('patientId') + create_order_select_hook_request(body: order_select_hook_request) + + result = run(test, + client_fhir_server:, + client_access_token: client_bearer_token) + + expect(result.result).to eq('fail') + expect(entity_result_message(test)).to match( + /order-select request context does not contain required field `patientId`/ + ) + end + + it 'fails if resource type and id cannot be extracted from context `userId` field' do + allow_any_instance_of(test).to receive(:resource_is_valid?).and_return(true) + stub_request(:get, "#{client_fhir_server}/Patient/example") + .with( + headers: { Authorization: 'Bearer SAMPLE_TOKEN' } + ) + .to_return(status: 200, body: crd_patient.to_json) + order_select_hook_request['context']['userId'] = '/' + create_order_select_hook_request(body: order_select_hook_request) + + result = run(test, + client_fhir_server:, + client_access_token: client_bearer_token) + + expect(result.result).to eq('fail') + expect(entity_result_message(test)).to match(/Invalid `userId` format./) + end + + it 'fails if context `userId` field resource type is not valid' do + allow_any_instance_of(test).to receive(:resource_is_valid?).and_return(true) + stub_request(:get, "#{client_fhir_server}/Patient/example") + .with( + headers: { Authorization: 'Bearer SAMPLE_TOKEN' } + ) + .to_return(status: 200, body: crd_patient.to_json) + order_select_hook_request['context']['userId'] = 'Observation/example' + create_order_select_hook_request(body: order_select_hook_request) + + result = run(test, + client_fhir_server:, + client_access_token: client_bearer_token) + + expect(result.result).to eq('fail') + expect(entity_result_message(test)).to match(/Unsupported resource type/) + end + + it 'fails if client fhir server returns non 200 response' do + allow_any_instance_of(test).to receive(:resource_is_valid?).and_return(true) + practitioner_resource_request = stub_request(:get, "#{client_fhir_server}/Practitioner/example") + .with( + headers: { Authorization: 'Bearer SAMPLE_TOKEN' } + ) + .to_return(status: 404) + patient_resource_request = stub_request(:get, "#{client_fhir_server}/Patient/example") + .with( + headers: { Authorization: 'Bearer SAMPLE_TOKEN' } + ) + .to_return(status: 200, body: crd_patient.to_json) + + create_order_select_hook_request(body: order_select_hook_request) + + result = run(test, + client_fhir_server:, + client_access_token: client_bearer_token) + + expect(result.result).to eq('fail') + expect(entity_result_message(test)).to match(/Unexpected response status: expected 200, but received 404/) + expect(practitioner_resource_request).to have_been_made + expect(patient_resource_request).to have_been_made + end + + it 'fails if retrieved fhir resource fails validation' do + validation_request = stub_request(:post, "#{validator_url}/validate") + .to_return(status: 200, body: operation_outcome_failure.to_json) + practitioner_resource_request = stub_request(:get, "#{client_fhir_server}/Practitioner/example") + .with( + headers: { Authorization: 'Bearer SAMPLE_TOKEN' } + ) + .to_return(status: 200, body: crd_practitioner.to_json) + patient_resource_request = stub_request(:get, "#{client_fhir_server}/Patient/example") + .with( + headers: { Authorization: 'Bearer SAMPLE_TOKEN' } + ) + .to_return(status: 200, body: crd_patient.to_json) + + create_order_select_hook_request(body: order_select_hook_request) + + result = run(test, + client_fhir_server:, + client_access_token: client_bearer_token) + + expect(result.result).to eq('fail') + expect(entity_result_message(test)).to match(/Resource does not conform to/) + expect(validation_request).to have_been_made.times(4) + expect(practitioner_resource_request).to have_been_made + expect(patient_resource_request).to have_been_made + end + + it 'passes if context contains optional `encounterId` field' do + validation_request = stub_request(:post, "#{validator_url}/validate") + .to_return(status: 200, body: operation_outcome_success.to_json) + patient_resource_request = stub_request(:get, "#{client_fhir_server}/Patient/example") + .with( + headers: { Authorization: 'Bearer SAMPLE_TOKEN' } + ) + .to_return(status: 200, body: crd_patient.to_json) + practitioner_resource_request = stub_request(:get, "#{client_fhir_server}/Practitioner/example") + .with( + headers: { Authorization: 'Bearer SAMPLE_TOKEN' } + ) + .to_return(status: 200, body: crd_practitioner.to_json) + encounter_resource_request = stub_request(:get, "#{client_fhir_server}/Encounter/example") + .with( + headers: { Authorization: 'Bearer SAMPLE_TOKEN' } + ) + .to_return(status: 200, body: crd_encounter.to_json) + + order_select_hook_request['context']['encounterId'] = 'example' + + create_order_select_hook_request(body: order_select_hook_request) + + result = run(test, + client_fhir_server:, + client_access_token: client_bearer_token) + + expect(result.result).to eq('pass') + expect(validation_request).to have_been_made.times(5) + expect(patient_resource_request).to have_been_made + expect(practitioner_resource_request).to have_been_made + expect(encounter_resource_request).to have_been_made + end + + it 'fails if context `draftOrders` is not a bundle' do + allow_any_instance_of(test).to receive(:resource_is_valid?).and_return(true) + stub_request(:get, "#{client_fhir_server}/Practitioner/example") + .with( + headers: { Authorization: 'Bearer SAMPLE_TOKEN' } + ) + .to_return(status: 200, body: crd_practitioner.to_json) + stub_request(:get, "#{client_fhir_server}/Patient/example") + .with( + headers: { Authorization: 'Bearer SAMPLE_TOKEN' } + ) + .to_return(status: 200, body: crd_patient.to_json) + + order_select_hook_request['context']['draftOrders'] = crd_patient + + create_order_select_hook_request(body: order_select_hook_request) + + result = run(test, + client_fhir_server:, + client_access_token: client_bearer_token) + + expect(result.result).to eq('fail') + expect(entity_result_message(test)).to match(/Wrong context resource type: Expected `Bundle`/) + end + + it 'fails if no resources in context `draftOrder` field are one of the supported resources' do + allow_any_instance_of(test).to receive(:resource_is_valid?).and_return(true) + stub_request(:get, "#{client_fhir_server}/Practitioner/example") + .with( + headers: { Authorization: 'Bearer SAMPLE_TOKEN' } + ) + .to_return(status: 200, body: crd_practitioner.to_json) + stub_request(:get, "#{client_fhir_server}/Patient/example") + .with( + headers: { Authorization: 'Bearer SAMPLE_TOKEN' } + ) + .to_return(status: 200, body: crd_patient.to_json) + + order_select_hook_request['context']['draftOrders']['entry'].each do |entry| + entry['resource'] = crd_patient + end + + create_order_select_hook_request(body: order_select_hook_request) + + result = run(test, + client_fhir_server:, + client_access_token: client_bearer_token) + + expect(result.result).to eq('fail') + expect(entity_result_message(test)).to match( + /`draftOrders` bundle must contain at least one of the expected resource types/ + ) + end + + it 'fails if all orders in context `draftOrders` field not in draft state' do + oo = + { + outcomes: [{ + issues: [{ + message: 'NutritionOrder#pureeddiet-simple is invalid', + level: 'ERROR' + }] + }], + sessionId: 'b8cf5547-1dc7-4714-a797-dc2347b93fe2' + } + + validation_request = stub_request(:post, "#{validator_url}/validate") + .with { |request| request.body.exclude?(nutrition_order_profile) } + .to_return(status: 200, body: operation_outcome_success.to_json) + nutrition_order_validation_request = stub_request(:post, "#{validator_url}/validate") + .with { |request| request.body.include?(nutrition_order_profile) } + .to_return(status: 200, body: oo.to_json) + + patient_resource_request = stub_request(:get, "#{client_fhir_server}/Patient/example") + .with( + headers: { Authorization: 'Bearer SAMPLE_TOKEN' } + ) + .to_return(status: 200, body: crd_patient.to_json) + practitioner_resource_request = stub_request(:get, "#{client_fhir_server}/Practitioner/example") + .with( + headers: { Authorization: 'Bearer SAMPLE_TOKEN' } + ) + .to_return(status: 200, body: crd_practitioner.to_json) + + order_select_hook_request['context']['draftOrders']['entry'][0]['resource']['status'] = 'active' + + create_order_select_hook_request(body: order_select_hook_request) + + result = run(test, + client_fhir_server:, + client_access_token: client_bearer_token) + + expect(result.result).to eq('fail') + expect(entity_result_message(test)).to match(/NutritionOrder#pureeddiet-simple is invalid/) + expect(validation_request).to have_been_made.times(3) + expect(nutrition_order_validation_request).to have_been_made + expect(patient_resource_request).to have_been_made + expect(practitioner_resource_request).to have_been_made + end + + it 'fails if context `selections` field contains an reference not found in the `draftOrders` bundle' do + allow_any_instance_of(test).to receive(:resource_is_valid?).and_return(true) + stub_request(:get, "#{client_fhir_server}/Practitioner/example") + .with( + headers: { Authorization: 'Bearer SAMPLE_TOKEN' } + ) + .to_return(status: 200, body: crd_practitioner.to_json) + stub_request(:get, "#{client_fhir_server}/Patient/example") + .with( + headers: { Authorization: 'Bearer SAMPLE_TOKEN' } + ) + .to_return(status: 200, body: crd_patient.to_json) + + order_select_hook_request['context']['draftOrders']['entry'][0]['resource']['id'] = 'new_id' + + create_order_select_hook_request(body: order_select_hook_request) + + result = run(test, + client_fhir_server:, + client_access_token: client_bearer_token) + + expect(result.result).to eq('fail') + expect(entity_result_message(test)).to match( + /`selections` field must reference FHIR resources in `draftOrders`./ + ) + end + end +end diff --git a/spec/davinci_crd_test_kit/hook_request_valid_context_test_order_sign_spec.rb b/spec/davinci_crd_test_kit/hook_request_valid_context_test_order_sign_spec.rb new file mode 100644 index 0000000..8424866 --- /dev/null +++ b/spec/davinci_crd_test_kit/hook_request_valid_context_test_order_sign_spec.rb @@ -0,0 +1,450 @@ +require_relative '../../lib/davinci_crd_test_kit/client_tests/hook_request_valid_context_test' +require_relative '../../lib/davinci_crd_test_kit/jwt_helper' + +RSpec.describe DaVinciCRDTestKit::HookRequestValidContextTest do + let(:suite) { Inferno::Repositories::TestSuites.new.find('crd_client') } + let(:session_data_repo) { Inferno::Repositories::SessionData.new } + let(:results_repo) { Inferno::Repositories::Results.new } + let(:test_session) { repo_create(:test_session, test_suite_id: 'crd_client') } + let(:klass) { Class.new(DaVinciCRDTestKit::JwtHelper) } + + let(:example_client_url) { 'https://cds.example.org' } + let(:base_url) { "#{Inferno::Application['base_url']}/custom/crd_client" } + let(:order_sign_url) { "#{base_url}/cds-services/order-sign-service" } + let(:client_fhir_server) { 'https://example/r4' } + let(:client_bearer_token) { 'SAMPLE_TOKEN' } + let(:nutrition_order_profile) { 'http://hl7.org/fhir/us/davinci-crd/StructureDefinition/profile-nutritionorder' } + + let(:order_sign_hook_request) do + JSON.parse( + File.read(File.join( + __dir__, '..', 'fixtures', 'order_sign_hook_request.json' + )) + ) + end + + let(:crd_patient) do + JSON.parse( + File.read(File.join( + __dir__, '..', 'fixtures', 'crd_patient_example.json' + )) + ) + end + + let(:crd_practitioner) do + JSON.parse( + File.read(File.join( + __dir__, '..', 'fixtures', 'crd_practitioner_example.json' + )) + ) + end + + let(:crd_encounter) do + JSON.parse( + File.read(File.join( + __dir__, '..', 'fixtures', 'crd_encounter_example.json' + )) + ) + end + + let(:operation_outcome_success) do + { + outcomes: [{ + issues: [] + }], + sessionId: 'b8cf5547-1dc7-4714-a797-dc2347b93fe2' + } + end + + let(:operation_outcome_failure) do + { + outcomes: [{ + issues: [{ + level: 'ERROR', + message: 'Resource does not conform to profile' + }] + }], + sessionId: 'b8cf5547-1dc7-4714-a797-dc2347b93fe2' + } + end + + let(:validator_url) { ENV.fetch('CRD_FHIR_RESOURCE_VALIDATOR_URL') } + + def run(runnable, inputs = {}) + test_run_params = { test_session_id: test_session.id }.merge(runnable.reference_hash) + test_run = Inferno::Repositories::TestRuns.new.create(test_run_params) + inputs.each do |name, value| + session_data_repo.save( + test_session_id: test_session.id, + name:, + value:, + type: runnable.config.input_type(name) + ) + end + Inferno::TestRunner.new(test_session:, test_run:).run(runnable) + end + + def create_order_sign_hook_request(url: order_sign_url, body: nil, status: 200) + auth_token = klass.build( + aud: order_sign_url, + iss: example_client_url, + jku: "#{example_client_url}/jwks.json", + encryption_method: 'RS384' + ) + + headers = [ + { + type: 'request', + name: 'Authorization', + value: "Bearer #{auth_token}" + } + ] + + repo_create( + :request, + name: 'order_sign', + direction: 'incoming', + url:, + test_session_id: test_session.id, + request_body: body.is_a?(Hash) ? body.to_json : body, + status:, + headers: + ) + end + + def entity_result_message(runnable) + results_repo.current_results_for_test_session_and_runnables(test_session.id, [runnable]) + .first + .messages + .map(&:message) + .join(' ') + end + + describe 'Order Sign Hook Valid Context' do + let(:test) do + Class.new(DaVinciCRDTestKit::HookRequestValidContextTest) do + fhir_resource_validator do + url ENV.fetch('CRD_FHIR_RESOURCE_VALIDATOR_URL') + + cli_context do + txServer nil + displayWarnings true + disableDefaultResourceFetcher true + end + + igs('hl7.fhir.us.davinci-crd', 'hl7.fhir.us.core') + end + config( + options: { hook_name: 'order-sign' }, + requests: { + hook_request: { name: :order_sign } + } + ) + end + end + + it 'passes if hook request `context` contains all required fields and fhir resources are valid' do + validation_request = stub_request(:post, "#{validator_url}/validate") + .to_return(status: 200, body: operation_outcome_success.to_json) + patient_resource_request = stub_request(:get, "#{client_fhir_server}/Patient/example") + .with( + headers: { Authorization: 'Bearer SAMPLE_TOKEN' } + ) + .to_return(status: 200, body: crd_patient.to_json) + practitioner_resource_request = stub_request(:get, "#{client_fhir_server}/Practitioner/example") + .with( + headers: { Authorization: 'Bearer SAMPLE_TOKEN' } + ) + .to_return(status: 200, body: crd_practitioner.to_json) + + create_order_sign_hook_request(body: order_sign_hook_request) + + result = run(test, + client_fhir_server:, + client_access_token: client_bearer_token) + + expect(result.result).to eq('pass') + expect(validation_request).to have_been_made.times(4) + expect(patient_resource_request).to have_been_made + expect(practitioner_resource_request).to have_been_made + end + + it 'skips if no client fhir server url is found' do + create_order_sign_hook_request(body: order_sign_hook_request) + + result = run(test) + + expect(result.result).to eq('skip') + expect(result.result_message).to eq( + "Input 'client_fhir_server' is nil, skipping test." + ) + end + + it 'fails if request body is not a valid json' do + create_order_sign_hook_request(body: 'invalid_request') + + result = run(test, + client_fhir_server:, + client_access_token: client_bearer_token) + + expect(result.result).to eq('fail') + expect(result.result_message).to eq('Invalid JSON. ') + end + + it 'fails if request body is does not contain the `context` field' do + invalid_hook_request = order_sign_hook_request.except('context') + create_order_sign_hook_request(body: invalid_hook_request) + + result = run(test, + client_fhir_server:, + client_access_token: client_bearer_token) + + expect(result.result).to eq('fail') + expect(result.result_message).to eq('Hook request does not contain required `context` field') + end + + it 'fails if context does not contain all required fields' do + allow_any_instance_of(test).to receive(:resource_is_valid?).and_return(true) + stub_request(:get, "#{client_fhir_server}/Practitioner/example") + .with( + headers: { Authorization: 'Bearer SAMPLE_TOKEN' } + ) + .to_return(status: 200, body: crd_practitioner.to_json) + order_sign_hook_request['context'].delete('patientId') + create_order_sign_hook_request(body: order_sign_hook_request) + + result = run(test, + client_fhir_server:, + client_access_token: client_bearer_token) + + expect(result.result).to eq('fail') + expect(entity_result_message(test)).to match( + /order-sign request context does not contain required field `patientId`/ + ) + end + + it 'fails if resource type and id cannot be extracted from context `userId` field' do + allow_any_instance_of(test).to receive(:resource_is_valid?).and_return(true) + stub_request(:get, "#{client_fhir_server}/Patient/example") + .with( + headers: { Authorization: 'Bearer SAMPLE_TOKEN' } + ) + .to_return(status: 200, body: crd_patient.to_json) + + order_sign_hook_request['context']['userId'] = '/' + create_order_sign_hook_request(body: order_sign_hook_request) + + result = run(test, + client_fhir_server:, + client_access_token: client_bearer_token) + + expect(result.result).to eq('fail') + expect(entity_result_message(test)).to match(/Invalid `userId` format./) + end + + it 'fails if context `userId` field resource type is not valid' do + allow_any_instance_of(test).to receive(:resource_is_valid?).and_return(true) + stub_request(:get, "#{client_fhir_server}/Patient/example") + .with( + headers: { Authorization: 'Bearer SAMPLE_TOKEN' } + ) + .to_return(status: 200, body: crd_patient.to_json) + + order_sign_hook_request['context']['userId'] = 'Observation/example' + create_order_sign_hook_request(body: order_sign_hook_request) + + result = run(test, + client_fhir_server:, + client_access_token: client_bearer_token) + + expect(result.result).to eq('fail') + expect(entity_result_message(test)).to match(/Unsupported resource type: `userId` type should be/) + end + + it 'fails if client fhir server returns non 200 response' do + practitioner_resource_request = stub_request(:get, "#{client_fhir_server}/Practitioner/example") + .with( + headers: { Authorization: 'Bearer SAMPLE_TOKEN' } + ) + .to_return(status: 404) + patient_resource_request = stub_request(:get, "#{client_fhir_server}/Patient/example") + .with( + headers: { Authorization: 'Bearer SAMPLE_TOKEN' } + ) + .to_return(status: 200, body: crd_patient.to_json) + + allow_any_instance_of(test).to receive(:resource_is_valid?).and_return(true) + + create_order_sign_hook_request(body: order_sign_hook_request) + + result = run(test, + client_fhir_server:, + client_access_token: client_bearer_token) + + expect(result.result).to eq('fail') + expect(entity_result_message(test)).to match(/Unexpected response status: expected 200, but received 404/) + expect(practitioner_resource_request).to have_been_made + expect(patient_resource_request).to have_been_made + end + + it 'fails if returned fhir resource fails validation' do + validation_request = stub_request(:post, "#{validator_url}/validate") + .to_return(status: 200, body: operation_outcome_failure.to_json) + practitioner_resource_request = stub_request(:get, "#{client_fhir_server}/Practitioner/example") + .with( + headers: { Authorization: 'Bearer SAMPLE_TOKEN' } + ) + .to_return(status: 200, body: crd_practitioner.to_json) + + stub_request(:get, "#{client_fhir_server}/Patient/example") + .with( + headers: { Authorization: 'Bearer SAMPLE_TOKEN' } + ) + .to_return(status: 200, body: crd_patient.to_json) + + create_order_sign_hook_request(body: order_sign_hook_request) + + result = run(test, + client_fhir_server:, + client_access_token: client_bearer_token) + + expect(result.result).to eq('fail') + expect(entity_result_message(test)).to match('Resource does not conform to profile') + expect(validation_request).to have_been_made.times(4) + expect(practitioner_resource_request).to have_been_made + end + + it 'passes if context contains optional `encounterId` field' do + validation_request = stub_request(:post, "#{validator_url}/validate") + .to_return(status: 200, body: operation_outcome_success.to_json) + patient_resource_request = stub_request(:get, "#{client_fhir_server}/Patient/example") + .with( + headers: { Authorization: 'Bearer SAMPLE_TOKEN' } + ) + .to_return(status: 200, body: crd_patient.to_json) + practitioner_resource_request = stub_request(:get, "#{client_fhir_server}/Practitioner/example") + .with( + headers: { Authorization: 'Bearer SAMPLE_TOKEN' } + ) + .to_return(status: 200, body: crd_practitioner.to_json) + encounter_resource_request = stub_request(:get, "#{client_fhir_server}/Encounter/example") + .with( + headers: { Authorization: 'Bearer SAMPLE_TOKEN' } + ) + .to_return(status: 200, body: crd_encounter.to_json) + + order_sign_hook_request['context']['encounterId'] = 'example' + + create_order_sign_hook_request(body: order_sign_hook_request) + + result = run(test, + client_fhir_server:, + client_access_token: client_bearer_token) + + expect(result.result).to eq('pass') + expect(validation_request).to have_been_made.times(5) + expect(patient_resource_request).to have_been_made + expect(practitioner_resource_request).to have_been_made + expect(encounter_resource_request).to have_been_made + end + + it 'fails if context `draftOrders` is not a bundle' do + allow_any_instance_of(test).to receive(:resource_is_valid?).and_return(true) + stub_request(:get, "#{client_fhir_server}/Patient/example") + .with( + headers: { Authorization: 'Bearer SAMPLE_TOKEN' } + ) + .to_return(status: 200, body: crd_patient.to_json) + stub_request(:get, "#{client_fhir_server}/Practitioner/example") + .with( + headers: { Authorization: 'Bearer SAMPLE_TOKEN' } + ) + .to_return(status: 200, body: crd_practitioner.to_json) + + order_sign_hook_request['context']['draftOrders'] = crd_patient + + create_order_sign_hook_request(body: order_sign_hook_request) + + result = run(test, + client_fhir_server:, + client_access_token: client_bearer_token) + + expect(result.result).to eq('fail') + expect(entity_result_message(test)).to match(/Wrong context resource type: Expected `Bundle`, got `Patient`/) + end + + it 'fails if no resources in context `draftOrder` field are one of the supported resources' do + allow_any_instance_of(test).to receive(:resource_is_valid?).and_return(true) + stub_request(:get, "#{client_fhir_server}/Patient/example") + .with( + headers: { Authorization: 'Bearer SAMPLE_TOKEN' } + ) + .to_return(status: 200, body: crd_patient.to_json) + stub_request(:get, "#{client_fhir_server}/Practitioner/example") + .with( + headers: { Authorization: 'Bearer SAMPLE_TOKEN' } + ) + .to_return(status: 200, body: crd_practitioner.to_json) + + order_sign_hook_request['context']['draftOrders']['entry'].each do |entry| + entry['resource'] = crd_patient + end + + create_order_sign_hook_request(body: order_sign_hook_request) + + result = run(test, + client_fhir_server:, + client_access_token: client_bearer_token) + + expect(result.result).to eq('fail') + expect(entity_result_message(test)).to match( + '`draftOrders` bundle must contain at least one of the expected resource types:' + ) + end + + it 'fails if all orders in context `draftOrders` field not in draft state' do + oo = + { + outcomes: [{ + issues: [{ + message: 'NutritionOrder#pureeddiet-simple is invalid', + level: 'ERROR' + }] + }], + sessionId: 'b8cf5547-1dc7-4714-a797-dc2347b93fe2' + } + + validation_request = stub_request(:post, "#{validator_url}/validate") + .with { |request| request.body.exclude?(nutrition_order_profile) } + .to_return(status: 200, body: operation_outcome_success.to_json) + nutrition_order_validation_request = stub_request(:post, "#{validator_url}/validate") + .with { |request| request.body.include?(nutrition_order_profile) } + .to_return(status: 200, body: oo.to_json) + patient_resource_request = stub_request(:get, "#{client_fhir_server}/Patient/example") + .with( + headers: { Authorization: 'Bearer SAMPLE_TOKEN' } + ) + .to_return(status: 200, body: crd_patient.to_json) + practitioner_resource_request = stub_request(:get, "#{client_fhir_server}/Practitioner/example") + .with( + headers: { Authorization: 'Bearer SAMPLE_TOKEN' } + ) + .to_return(status: 200, body: crd_practitioner.to_json) + + order_sign_hook_request['context']['draftOrders']['entry'][0]['resource']['status'] = 'active' + + create_order_sign_hook_request(body: order_sign_hook_request) + + result = run(test, + client_fhir_server:, + client_access_token: client_bearer_token) + + expect(result.result).to eq('fail') + expect(entity_result_message(test)).to match(/NutritionOrder#pureeddiet-simple is invalid/) + expect(validation_request).to have_been_made.times(3) + expect(nutrition_order_validation_request).to have_been_made + expect(patient_resource_request).to have_been_made + expect(practitioner_resource_request).to have_been_made + end + end +end diff --git a/spec/davinci_crd_test_kit/hook_request_valid_prefetch_test_spec.rb b/spec/davinci_crd_test_kit/hook_request_valid_prefetch_test_spec.rb new file mode 100644 index 0000000..54519ba --- /dev/null +++ b/spec/davinci_crd_test_kit/hook_request_valid_prefetch_test_spec.rb @@ -0,0 +1,478 @@ +require_relative '../../lib/davinci_crd_test_kit/client_tests/hook_request_valid_prefetch_test' +require_relative '../../lib/davinci_crd_test_kit/jwt_helper' + +RSpec.describe DaVinciCRDTestKit::HookRequestValidPrefetchTest do + let(:suite) { Inferno::Repositories::TestSuites.new.find('crd_client') } + let(:session_data_repo) { Inferno::Repositories::SessionData.new } + let(:test_session) { repo_create(:test_session, test_suite_id: 'crd_client') } + let(:jwt_helper) { Class.new(DaVinciCRDTestKit::JwtHelper) } + + let(:example_client_url) { 'https://cds.example.org' } + let(:base_url) { "#{Inferno::Application['base_url']}/custom/crd_client" } + let(:appointment_book_url) { "#{base_url}/cds-services/appointment-book-service" } + let(:client_fhir_server) { 'https://example/r4' } + let(:client_bearer_token) { 'SAMPLE_TOKEN' } + + let(:appointment_book_hook_request) do + JSON.parse( + File.read(File.join( + __dir__, '..', 'fixtures', 'appointment_book_hook_request.json' + )) + ) + end + let(:encounter_start_hook_request) do + JSON.parse( + File.read(File.join( + __dir__, '..', 'fixtures', 'encounter_start_hook_request.json' + )) + ) + end + let(:order_dispatch_hook_request) do + JSON.parse( + File.read(File.join( + __dir__, '..', 'fixtures', 'order_dispatch_hook_request.json' + )) + ) + end + let(:order_select_hook_request) do + JSON.parse( + File.read(File.join( + __dir__, '..', 'fixtures', 'order_select_hook_request.json' + )) + ) + end + + let(:crd_patient) do + JSON.parse( + File.read(File.join( + __dir__, '..', 'fixtures', 'crd_patient_example.json' + )) + ) + end + + let(:crd_practitioner) do + JSON.parse( + File.read(File.join( + __dir__, '..', 'fixtures', 'crd_practitioner_example.json' + )) + ) + end + + let(:crd_encounter) do + JSON.parse( + File.read(File.join( + __dir__, '..', 'fixtures', 'crd_encounter_example.json' + )) + ) + end + + let(:crd_coverage) do + JSON.parse( + File.read(File.join( + __dir__, '..', 'fixtures', 'crd_coverage_example.json' + )) + ) + end + + let(:crd_coverage_bundle) do + bundle = FHIR::Bundle.new(type: 'searchset') + bundle.entry.append(FHIR::Bundle::Entry.new( + fullUrl: "#{example_client_url}/Coverage/coverage_example", + resource: FHIR.from_contents(crd_coverage.to_json) + )) + bundle + end + + let(:crd_service_request) do + JSON.parse( + File.read(File.join( + __dir__, '..', 'fixtures', 'crd_service_request_example.json' + )) + ) + end + + let(:appointment_book_hook_request_with_prefetch) do + request = appointment_book_hook_request + request['prefetch'] = { user: crd_practitioner, patient: crd_patient, coverage: crd_coverage_bundle } + request + end + let(:encounter_start_hook_request_with_prefetch) do + request = encounter_start_hook_request + request['prefetch'] = { user: crd_practitioner, patient: crd_patient, encounter: crd_encounter, + coverage: crd_coverage_bundle } + request + end + let(:order_dispatch_hook_request_with_prefetch) do + request = order_dispatch_hook_request + request['prefetch'] = { performer: crd_practitioner, patient: crd_patient, order: crd_service_request, + coverage: crd_coverage_bundle } + request + end + let(:order_select_hook_request_with_prefetch) do + request = order_select_hook_request + request['prefetch'] = { user: crd_practitioner, patient: crd_patient, coverage: crd_coverage_bundle } + request + end + + let(:operation_outcome_success) do + { + outcomes: [{ + issues: [] + }], + sessionId: 'b8cf5547-1dc7-4714-a797-dc2347b93fe2' + } + end + + let(:operation_outcome_failure) do + { + outcomes: [{ + issues: [{ + level: 'ERROR', + message: 'Resource does not conform to profile' + }] + }], + sessionId: 'b8cf5547-1dc7-4714-a797-dc2347b93fe2' + } + end + + let(:validator_url) { ENV.fetch('CRD_FHIR_RESOURCE_VALIDATOR_URL') } + + def run(runnable, inputs = {}) + test_run_params = { test_session_id: test_session.id }.merge(runnable.reference_hash) + test_run = Inferno::Repositories::TestRuns.new.create(test_run_params) + inputs.each do |name, value| + session_data_repo.save( + test_session_id: test_session.id, + name:, + value:, + type: runnable.config.input_type(name) + ) + end + Inferno::TestRunner.new(test_session:, test_run:).run(runnable) + end + + def create_hook_request(url: appointment_book_url, body: nil, status: 200, hook_name: 'appointment_book') + auth_token = jwt_helper.build( + aud: appointment_book_url, + iss: example_client_url, + jku: "#{example_client_url}/jwks.json", + encryption_method: 'RS384' + ) + + headers = [ + { + type: 'request', + name: 'Authorization', + value: "Bearer #{auth_token}" + } + ] + + repo_create( + :request, + name: hook_name, + direction: 'incoming', + url:, + test_session_id: test_session.id, + request_body: body.is_a?(Hash) ? body.to_json : body, + status:, + headers: + ) + end + + describe 'Appointment Book Hook Valid Prefetch' do + let(:test) do + Class.new(DaVinciCRDTestKit::HookRequestValidPrefetchTest) do + fhir_resource_validator do + url ENV.fetch('CRD_FHIR_RESOURCE_VALIDATOR_URL') + + cli_context do + txServer nil + displayWarnings true + disableDefaultResourceFetcher true + end + + igs('hl7.fhir.us.davinci-crd', 'hl7.fhir.us.core') + end + config( + options: { hook_name: 'appointment-book' }, + requests: { hook_request: { name: :appointment_book } } + ) + end + end + + it 'passes if prefetch contains valid resources for `user`, `patient`, and `coverage` fields' do + validation_request = stub_request(:post, "#{validator_url}/validate") + .to_return(status: 200, body: operation_outcome_success.to_json) + + create_hook_request(body: appointment_book_hook_request_with_prefetch) + + result = run(test) + expect(result.result).to eq('pass') + expect(validation_request).to have_been_made.times(3) + end + + it 'skips if hook request does not contain the `prefetch` field' do + create_hook_request(body: appointment_book_hook_request) + + result = run(test) + + expect(result.result).to eq('skip') + expect(result.result_message).to eq('Received hook request does not contain the `prefetch` field.') + end + + it 'skips if hook request does not contain the `context` field' do + appointment_book_hook_no_context = appointment_book_hook_request_with_prefetch.except('context') + create_hook_request(body: appointment_book_hook_no_context) + + result = run(test) + + expect(result.result).to eq('skip') + expect(result.result_message).to match('Received hook request does not contain the `context` field') + end + + it 'fails if prefetch `user` is not a valid resource type' do + allow_any_instance_of(test).to receive(:resource_is_valid?).and_return(true) + + appointment_book_hook_request_with_prefetch['prefetch'][:user]['resourceType'] = 'Observation' + + create_hook_request(body: appointment_book_hook_request_with_prefetch) + + result = run(test) + + expect(result.result).to eq('fail') + expect(result.result_message).to match( + 'Unexpected resource type: expected Practitioner, but received Observation' + ) + end + + it 'fails if prefetch `user` fails validation' do + practitioner_validation_request = stub_request(:post, "#{validator_url}/validate") + .to_return(status: 200, body: operation_outcome_failure.to_json) + + create_hook_request(body: appointment_book_hook_request_with_prefetch) + + result = run(test) + + expect(result.result).to eq('fail') + expect(result.result_message).to match('Resource does not conform to the profile: http://hl7.org/fhir/us/davinci-crd/StructureDefinition/profile-practitioner') + expect(practitioner_validation_request).to have_been_made + end + + it 'fails if prefetch `user` has wrong id' do + allow_any_instance_of(test).to receive(:resource_is_valid?).and_return(true) + + appointment_book_hook_request_with_prefetch['prefetch'][:user]['id'] = 'incorrect_id' + + create_hook_request(body: appointment_book_hook_request_with_prefetch) + + result = run(test) + + expect(result.result).to eq('fail') + expect(result.result_message).to match("Expected `user` field's FHIR resource to have an `id` of 'example'") + end + + it 'fails if prefetch `patient` is not a Patient resource' do + allow_any_instance_of(test).to receive(:resource_is_valid?).and_return(true) + + appointment_book_hook_request_with_prefetch['prefetch'][:patient]['resourceType'] = 'Practitioner' + + create_hook_request(body: appointment_book_hook_request_with_prefetch) + + result = run(test) + + expect(result.result).to eq('fail') + expect(result.result_message).to match('Unexpected resource type: expected Patient, but received Practitioner') + end + + it 'fails if prefetch `patient` fails validation' do + patient_validation_request = stub_request(:post, "#{validator_url}/validate") + .to_return(status: 200, body: operation_outcome_failure.to_json) + + appointment_book_hook_request_with_prefetch['prefetch'].delete(:user) + appointment_book_hook_request_with_prefetch['prefetch'].delete(:coverage) + + create_hook_request(body: appointment_book_hook_request_with_prefetch) + + result = run(test) + + expect(result.result).to eq('fail') + expect(result.result_message).to match('Resource does not conform to the profile: http://hl7.org/fhir/us/davinci-crd/StructureDefinition/profile-patient') + expect(patient_validation_request).to have_been_made + end + + it 'fails if prefetch `patient` has wrong id' do + allow_any_instance_of(test).to receive(:resource_is_valid?).and_return(true) + + appointment_book_hook_request_with_prefetch['prefetch'][:patient]['id'] = 'incorrect_id' + + create_hook_request(body: appointment_book_hook_request_with_prefetch) + + result = run(test) + + expect(result.result).to eq('fail') + expect(result.result_message).to match("Expected `patient` field's FHIR resource to have an `id` of 'example'") + end + + it 'fails if prefetch `coverage` is not a Coverage resource' do + allow_any_instance_of(test).to receive(:resource_is_valid?).and_return(true) + + appointment_book_hook_request_with_prefetch['prefetch'][:coverage].entry.first.resource = + FHIR.from_contents(crd_practitioner.to_json) + + create_hook_request(body: appointment_book_hook_request_with_prefetch) + + result = run(test) + + expect(result.result).to eq('fail') + expect(result.result_message).to match('Unexpected resource type: expected Coverage, but received Practitioner') + end + + it 'fails if prefetch `coverage` fails validation' do + coverage_validation_request = stub_request(:post, "#{validator_url}/validate") + .to_return(status: 200, body: operation_outcome_failure.to_json) + + appointment_book_hook_request_with_prefetch['prefetch'].delete(:user) + appointment_book_hook_request_with_prefetch['prefetch'].delete(:patient) + + create_hook_request(body: appointment_book_hook_request_with_prefetch) + + result = run(test) + + expect(result.result).to eq('fail') + expect(result.result_message).to match('Resource does not conform to the profile: http://hl7.org/fhir/us/davinci-crd/StructureDefinition/profile-coverage') + expect(coverage_validation_request).to have_been_made + end + + it 'fails if prefetch `coverage` has wrong beneficiary id' do + allow_any_instance_of(test).to receive(:resource_is_valid?).and_return(true) + + appointment_book_hook_request_with_prefetch['prefetch'][:coverage].entry.first.resource.beneficiary.reference = + 'Patient/incorrect_id' + + create_hook_request(body: appointment_book_hook_request_with_prefetch) + + result = run(test) + + expect(result.result).to eq('fail') + expect(result.result_message).to match( + "Expected `coverage` field's Coverage resource to have a `beneficiary` reference id of" + ) + end + + it 'fails if prefetch `coverage` has wrong status' do + allow_any_instance_of(test).to receive(:resource_is_valid?).and_return(true) + appointment_book_hook_request_with_prefetch['prefetch'][:coverage].entry.first.resource.status = + 'draft' + + create_hook_request(body: appointment_book_hook_request_with_prefetch) + + result = run(test) + expect(result.result).to eq('fail') + expect(result.result_message).to match( + "Expected `coverage` field's Coverage resource to have a `status`" + ) + end + end + + describe 'Encounter Start Hook Valid Prefetch' do + let(:test) do + Class.new(DaVinciCRDTestKit::HookRequestValidPrefetchTest) do + fhir_resource_validator do + url ENV.fetch('CRD_FHIR_RESOURCE_VALIDATOR_URL') + + cli_context do + txServer nil + displayWarnings true + disableDefaultResourceFetcher true + end + + igs('hl7.fhir.us.davinci-crd', 'hl7.fhir.us.core') + end + config( + options: { hook_name: 'encounter-start' }, + requests: { hook_request: { name: :encounter_start } } + ) + end + end + + it 'passes if prefetch contains valid resources for `user`, `patient`, and `encounter` fields' do + validation_request = stub_request(:post, "#{validator_url}/validate") + .to_return(status: 200, body: operation_outcome_success.to_json) + + create_hook_request(body: encounter_start_hook_request_with_prefetch, hook_name: 'encounter_start') + + result = run(test) + + expect(result.result).to eq('pass') + expect(validation_request).to have_been_made.times(4) + end + end + + describe 'Order Dispatch Hook Valid Prefetch' do + let(:test) do + Class.new(DaVinciCRDTestKit::HookRequestValidPrefetchTest) do + fhir_resource_validator do + url ENV.fetch('CRD_FHIR_RESOURCE_VALIDATOR_URL') + + cli_context do + txServer nil + displayWarnings true + disableDefaultResourceFetcher true + end + + igs('hl7.fhir.us.davinci-crd', 'hl7.fhir.us.core') + end + config( + options: { hook_name: 'order-dispatch' }, + requests: { hook_request: { name: :order_dispatch } } + ) + end + end + + it 'passes if prefetch contains valid resources for `performer`, `patient`, and `order` fields' do + validation_request = stub_request(:post, "#{validator_url}/validate") + .to_return(status: 200, body: operation_outcome_success.to_json) + + create_hook_request(body: order_dispatch_hook_request_with_prefetch, hook_name: 'order_dispatch') + + result = run(test) + + expect(result.result).to eq('pass') + expect(validation_request).to have_been_made.times(4) + end + end + + describe 'Order Select Hook Valid Prefetch' do + let(:test) do + Class.new(DaVinciCRDTestKit::HookRequestValidPrefetchTest) do + fhir_resource_validator do + url ENV.fetch('CRD_FHIR_RESOURCE_VALIDATOR_URL') + + cli_context do + txServer nil + displayWarnings true + disableDefaultResourceFetcher true + end + + igs('hl7.fhir.us.davinci-crd', 'hl7.fhir.us.core') + end + config( + options: { hook_name: 'order-select' }, + requests: { hook_request: { name: :order_select } } + ) + end + end + + it 'passes if prefetch contains valid resources for `user` and `patient` fields' do + validation_request = stub_request(:post, "#{validator_url}/validate") + .to_return(status: 200, body: operation_outcome_success.to_json) + + create_hook_request(body: order_select_hook_request_with_prefetch, hook_name: 'order_select') + + result = run(test) + + expect(result.result).to eq('pass') + expect(validation_request).to have_been_made.times(3) + end + end +end diff --git a/spec/davinci_crd_test_kit/instructions_card_received_across_hooks_test_spec.rb b/spec/davinci_crd_test_kit/instructions_card_received_across_hooks_test_spec.rb new file mode 100644 index 0000000..1320aa2 --- /dev/null +++ b/spec/davinci_crd_test_kit/instructions_card_received_across_hooks_test_spec.rb @@ -0,0 +1,47 @@ +RSpec.describe DaVinciCRDTestKit::InstructionsCardReceivedAcrossHooksTest do + let(:runnable_across) do + id = 'crd_server-crd_server_hooks-crd_server_required_card_response_validation' \ + '-crd_valid_instructions_card_received_across_hooks' + Inferno::Repositories::Tests.new.find(id) + end + let(:runnable_within) do + Inferno::Repositories::Tests.new + .find('crd_server-crd_server_hooks-crd_server_order_dispatch-crd_valid_instructions_card_received') + end + let(:session_data_repo) { Inferno::Repositories::SessionData.new } + let(:test_session) { repo_create(:test_session, test_suite_id: 'crd_server') } + let(:valid_response_body) do + File.read(File.join(__dir__, '..', 'fixtures', 'crd_authorization_hook_response.json')) + end + let(:cards) do + JSON.parse(valid_response_body)['cards'] + end + let(:base_url) { 'http://example.com/' } + + def run(runnable, inputs = {}) + test_run_params = { test_session_id: test_session.id }.merge(runnable.reference_hash) + test_run = Inferno::Repositories::TestRuns.new.create(test_run_params) + inputs.each do |name, value| + session_data_repo.save( + test_session_id: test_session.id, + name: name == :valid_cards ? :order_dispatch_valid_cards : name, + value:, + type: runnable.config.input_type(name) + ) + end + Inferno::TestRunner.new(test_session:, test_run:).run(runnable) + end + + it 'passes if an Instructions card is present' do + run(runnable_within, valid_cards: cards.to_json, base_url:) + result = run(runnable_across) + expect(result.result).to eq('pass') + end + + it 'skips if no instructions card present' do + run(runnable_within, valid_cards: [].to_json, base_url:) + result = run(runnable_across) + expect(result.result).to eq('skip') + expect(result.result_message).to match(/None of the hooks invoked returned a valid Instructions card/) + end +end diff --git a/spec/davinci_crd_test_kit/instructions_card_received_test_spec.rb b/spec/davinci_crd_test_kit/instructions_card_received_test_spec.rb new file mode 100644 index 0000000..0f93b0a --- /dev/null +++ b/spec/davinci_crd_test_kit/instructions_card_received_test_spec.rb @@ -0,0 +1,49 @@ +RSpec.describe DaVinciCRDTestKit::InstructionsCardReceivedTest do + let(:runnable) { Inferno::Repositories::Tests.new.find('crd_valid_instructions_card_received') } + let(:session_data_repo) { Inferno::Repositories::SessionData.new } + let(:test_session) { repo_create(:test_session, test_suite_id: 'crd_server') } + let(:cards) do + response_body = File.read(File.join(__dir__, '..', 'fixtures', 'crd_authorization_hook_response.json')) + JSON.parse(response_body)['cards'] + end + + def run(runnable, inputs = {}) + test_run_params = { test_session_id: test_session.id }.merge(runnable.reference_hash) + test_run = Inferno::Repositories::TestRuns.new.create(test_run_params) + inputs.each do |name, value| + session_data_repo.save( + test_session_id: test_session.id, + name:, + value:, + type: runnable.config.input_type(name) + ) + end + Inferno::TestRunner.new(test_session:, test_run:).run(runnable) + end + + it 'passes if cards contain an Instructions card' do + result = run(runnable, valid_cards: cards.to_json) + expect(result.result).to eq('pass') + end + + it 'skips if valid_cards not present' do + result = run(runnable) + expect(result.result).to eq('skip') + expect(result.result_message).to match(/'valid_cards' is nil, skipping test/) + end + + it 'fails if valid_cards is not json' do + result = run(runnable, valid_cards: '[[') + expect(result.result).to eq('fail') + expect(result.result_message).to match(/Invalid JSON/) + end + + it 'fails if no instructions card present' do + dup_cards = cards.deep_dup + dup_cards.delete(dup_cards.find { |card| card['links'].blank? }) + + result = run(runnable, valid_cards: dup_cards.to_json) + expect(result.result).to eq('fail') + expect(result.result_message).to match(/did not contain an Instructions card/) + end +end diff --git a/spec/davinci_crd_test_kit/jwt_helper_spec.rb b/spec/davinci_crd_test_kit/jwt_helper_spec.rb new file mode 100644 index 0000000..18ca9e5 --- /dev/null +++ b/spec/davinci_crd_test_kit/jwt_helper_spec.rb @@ -0,0 +1,57 @@ +RSpec.describe DaVinciCRDTestKit::JwtHelper do + let(:encryption_methods) { ['ES384', 'RS384'] } + let(:aud) { 'AUD' } + let(:iss) { 'ISS' } + let(:jku) { 'JKU' } + let(:jwks_hash) { JSON.parse(DaVinciCRDTestKit::JWKS.jwks_json) } + + def build_and_decode_jwt(encryption_method, kid = nil) + jwt = described_class.build(aud:, encryption_method:, iss:, jku:, kid:) + described_class.decode_jwt(jwt, jwks_hash, kid) + end + + describe '#build' do + context 'with unspecified key id' do + it 'creates a valid JWT' do + encryption_methods.each do |encryption_method| + payload, header = build_and_decode_jwt(encryption_method) + + expect(header['alg']).to eq(encryption_method) + expect(header['typ']).to eq('JWT') + expect(header['kid']).to be_present + expect(payload['iss']).to eq(iss) + expect(payload['aud']).to eq(aud) + expect(payload['iat']).to be_present + expect(payload['exp']).to be_present + expect(payload['jti']).to be_present + end + end + end + + context 'with specified key id' do + it 'creates a valid JWT with correct algorithm and kid' do + encryption_method = 'ES384' + kid = '4b49a739d1eb115b3225f4cf9beb6d1b' + payload, header = build_and_decode_jwt(encryption_method, kid) + + expect(header['alg']).to eq(encryption_method) + expect(header['typ']).to eq('JWT') + expect(header['kid']).to eq(kid) + expect(payload['iss']).to eq(iss) + expect(payload['aud']).to eq(aud) + expect(payload['iat']).to be_present + expect(payload['exp']).to be_present + expect(payload['jti']).to be_present + end + end + + it 'throws exception when key id not found for algorithm' do + encryption_method = 'RS384' + kid = '4b49a739d1eb115b3225f4cf9beb6d1b' + + expect do + build_and_decode_jwt(encryption_method, kid) + end.to raise_error(Inferno::Exceptions::AssertionException) + end + end +end diff --git a/spec/davinci_crd_test_kit/launch_smart_app_card_validation_test_spec.rb b/spec/davinci_crd_test_kit/launch_smart_app_card_validation_test_spec.rb new file mode 100644 index 0000000..6cfde9c --- /dev/null +++ b/spec/davinci_crd_test_kit/launch_smart_app_card_validation_test_spec.rb @@ -0,0 +1,48 @@ +RSpec.describe DaVinciCRDTestKit::LaunchSmartAppCardValidationTest do + let(:runnable) { Inferno::Repositories::Tests.new.find('crd_launch_smart_app_card_validation') } + let(:session_data_repo) { Inferno::Repositories::SessionData.new } + let(:results_repo) { Inferno::Repositories::Results.new } + let(:test_session) { repo_create(:test_session, test_suite_id: 'crd_server') } + let(:valid_cards) do + json = File.read(File.join(__dir__, '..', 'fixtures', 'valid_cards.json')) + JSON.parse(json) + end + let(:valid_cards_with_links) { valid_cards.filter { |card| card['links'].present? } } + + def run(runnable, inputs = {}) + test_run_params = { test_session_id: test_session.id }.merge(runnable.reference_hash) + test_run = Inferno::Repositories::TestRuns.new.create(test_run_params) + inputs.each do |name, value| + session_data_repo.save( + test_session_id: test_session.id, + name:, + value:, + type: runnable.config.input_type(name) + ) + end + Inferno::TestRunner.new(test_session:, test_run:).run(runnable) + end + + it 'passes if cards contain a valid Launch SMART App card' do + result = run(runnable, valid_cards_with_links: valid_cards_with_links.to_json) + expect(result.result).to eq('pass') + end + + it 'skips if valid_cards_with_links not present' do + result = run(runnable) + expect(result.result).to eq('skip') + expect(result.result_message).to match(/'valid_cards_with_links' is nil, skipping test/) + end + + it 'fails if valid_cards_with_links is not json' do + result = run(runnable, valid_cards_with_links: '[[') + expect(result.result).to eq('fail') + expect(result.result_message).to match(/Invalid JSON/) + end + + it 'skips if no Launch SMART App card present' do + result = run(runnable, valid_cards_with_links: [].to_json) + expect(result.result).to eq('skip') + expect(result.result_message).to match(/does not contain any Launch SMART App cards/) + end +end diff --git a/spec/davinci_crd_test_kit/order_dispatch_receive_request_test_spec.rb b/spec/davinci_crd_test_kit/order_dispatch_receive_request_test_spec.rb new file mode 100644 index 0000000..0ae4a57 --- /dev/null +++ b/spec/davinci_crd_test_kit/order_dispatch_receive_request_test_spec.rb @@ -0,0 +1,305 @@ +require_relative '../../lib/davinci_crd_test_kit/client_tests/order_dispatch_receive_request_test' +require_relative '../request_helper' + +RSpec.describe DaVinciCRDTestKit::OrderDispatchReceiveRequestTest do + include Rack::Test::Methods + include RequestHelpers + + let(:suite) { Inferno::Repositories::TestSuites.new.find('crd_client') } + let(:test) { Inferno::Repositories::Tests.new.find('crd_order_dispatch_request') } + let(:session_data_repo) { Inferno::Repositories::SessionData.new } + let(:results_repo) { Inferno::Repositories::Results.new } + let(:test_session) { repo_create(:test_session, test_suite_id: 'crd_client') } + let(:jwt_helper) { Class.new(DaVinciCRDTestKit::JwtHelper) } + + let(:example_client_url) { 'https://cds.example.org' } + let(:base_url) { "#{Inferno::Application['base_url']}/custom/crd_client" } + let(:order_dispatch_url) { "#{base_url}/cds-services/order-dispatch-service" } + let(:client_fhir_server) { 'https://example/r4' } + let(:patient_id) { 'example' } + let(:order_dispatch_selected_response_types) { ['instructions', 'coverage_information', 'external_reference'] } + + let(:server_endpoint) { '/custom/crd_client/cds-services/order-dispatch-service' } + let(:body) do + JSON.parse(File.read(File.join( + __dir__, '..', 'fixtures', 'order_dispatch_hook_request.json' + ))) + end + + let(:crd_order) do + JSON.parse(File.read(File.join( + __dir__, '..', 'fixtures', 'crd_service_request_example.json' + ))) + end + let(:crd_coverage) do + JSON.parse(File.read(File.join( + __dir__, '..', 'fixtures', 'crd_coverage_example.json' + ))) + end + let(:crd_coverage_bundle) do + bundle = FHIR::Bundle.new(type: 'searchset') + bundle.entry.append(FHIR::Bundle::Entry.new( + fullUrl: 'https://example.com/base/Coverage/coverage_example', + resource: FHIR.from_contents(crd_coverage.to_json) + )) + bundle + end + + def run(runnable, inputs = {}) + test_run_params = { test_session_id: test_session.id }.merge(runnable.reference_hash) + test_run = Inferno::Repositories::TestRuns.new.create(test_run_params) + inputs.each do |name, value| + session_data_repo.save( + test_session_id: test_session.id, + name:, + value:, + type: runnable.config.input_type(name) + ) + end + Inferno::TestRunner.new(test_session:, test_run:).run(runnable) + end + + it 'passes and responds 200 if request sent to the provided URL and jwt `iss` claim matches the given`iss`' do + allow(test).to receive(:suite).and_return(suite) + + token = jwt_helper.build( + aud: order_dispatch_url, + iss: example_client_url, + jku: "#{example_client_url}/jwks.json", + encryption_method: 'RS384' + ) + + result = run(test, iss: example_client_url, order_dispatch_selected_response_types:) + + expect(result.result).to eq('wait') + + body['prefetch'] = { 'coverage' => crd_coverage, 'order' => crd_order } + header('Authorization', "Bearer #{token}") + post_json(server_endpoint, body) + + expect(last_response).to be_ok + result = results_repo.find(result.id) + expect(result.result).to eq('pass') + end + + it 'returns cards and systemActions and uses client information to build request' do + allow(test).to receive(:suite).and_return(suite) + + token = jwt_helper.build( + aud: order_dispatch_url, + iss: example_client_url, + jku: "#{example_client_url}/jwks.json", + encryption_method: 'RS384' + ) + + run(test, iss: example_client_url, order_dispatch_selected_response_types:) + + body['prefetch'] = { 'coverage' => crd_coverage, 'order' => crd_order } + header('Authorization', "Bearer #{token}") + post_json(server_endpoint, body) + + expect(last_response).to be_ok + + card_response = JSON.parse(last_response.body) + cards = card_response['cards'] + system_actions = card_response['systemActions'] + + expect(cards.length).to eq(2) + expect(system_actions.length).to eq(1) + expect(system_actions.first['resource']['id']).to eq('example') + + order_extension = system_actions.first['resource']['extension'] + coverage_extension = order_extension.first['extension'].first + + expect(coverage_extension['url']).to eq('coverage') + expect(coverage_extension['valueReference']['reference']).to eq("Coverage/#{crd_coverage['id']}") + end + + it 'queries the client\'s FHIR server if coverage is not present in the prefetch' do + coverage_search_request = stub_request(:get, + "#{client_fhir_server}/Coverage?patient=#{patient_id}&status=active") + .with( + headers: { 'Authorization' => 'Bearer SAMPLE_TOKEN' } + ) + .to_return(status: 200, body: crd_coverage_bundle.to_json) + order_request = stub_request(:get, + "#{client_fhir_server}/ServiceRequest/example") + .with( + headers: { 'Authorization' => 'Bearer SAMPLE_TOKEN' } + ) + .to_return(status: 200, body: crd_order.to_json) + + allow(test).to receive(:suite).and_return(suite) + + token = jwt_helper.build( + aud: order_dispatch_url, + iss: example_client_url, + jku: "#{example_client_url}/jwks.json", + encryption_method: 'RS384' + ) + + run(test, iss: example_client_url, order_dispatch_selected_response_types:) + + header('Authorization', "Bearer #{token}") + post_json(server_endpoint, body) + expect(last_response).to be_ok + + card_response = JSON.parse(last_response.body) + cards = card_response['cards'] + system_actions = card_response['systemActions'] + + expect(cards.length).to eq(2) + expect(system_actions.length).to eq(1) + expect(system_actions.first['resource']['id']).to eq('example') + + order_extension = system_actions.first['resource']['extension'] + coverage_extension = order_extension.first['extension'].first + + expect(coverage_extension['url']).to eq('coverage') + expect(coverage_extension['valueReference']['reference']).to eq("Coverage/#{crd_coverage['id']}") + expect(coverage_search_request).to have_been_made + expect(order_request).to have_been_made + end + + it 'waits and responds with 500 if request sent to the provided URL and jwt `iss` claim mismatches the given `iss`' do + allow(test).to receive(:suite).and_return(suite) + + token = jwt_helper.build( + aud: order_dispatch_url, + iss: example_client_url, + jku: "#{example_client_url}/jwks.json", + encryption_method: 'RS384' + ) + + result = run(test, iss: 'example.com', order_dispatch_selected_response_types:) + + expect(result.result).to eq('wait') + + body['prefetch'] = { 'coverage' => crd_coverage, 'order' => crd_order } + header('Authorization', "Bearer #{token}") + post_json(server_endpoint, body.to_json) + + expect(last_response).to be_server_error + expect(last_response.body).to match(/find test run with identifier/) + result = results_repo.find(result.id) + expect(result.result).to eq('wait') + end + + it 'waits and responds with 500 if request sent to the provided URL contains the wrong hook name' do + allow(test).to receive(:suite).and_return(suite) + + token = jwt_helper.build( + aud: order_dispatch_url, + iss: example_client_url, + jku: "#{example_client_url}/jwks.json", + encryption_method: 'RS384' + ) + + result = run(test, iss: 'example.com', order_dispatch_selected_response_types:) + + expect(result.result).to eq('wait') + + body['prefetch'] = { 'coverage' => crd_coverage } + body['hook'] = 'incorrect-hook' + header('Authorization', "Bearer #{token}") + post_json(server_endpoint, body.to_json) + + expect(last_response).to be_server_error + expect(last_response.body).to match(/find test run with identifier/) + result = results_repo.find(result.id) + expect(result.result).to eq('wait') + end + + it 'returns default cards when no order_dispatch_selected_response_types selected' do + order_request = stub_request(:get, + "#{client_fhir_server}/ServiceRequest/example") + .with( + headers: { 'Authorization' => 'Bearer SAMPLE_TOKEN' } + ) + .to_return(status: 200, body: crd_order.to_json) + + allow(test).to receive(:suite).and_return(suite) + + token = jwt_helper.build( + aud: order_dispatch_url, + iss: example_client_url, + jku: "#{example_client_url}/jwks.json", + encryption_method: 'RS384' + ) + + run(test, iss: example_client_url, order_dispatch_selected_response_types: []) + + body['prefetch'] = { 'coverage' => crd_coverage } + header('Authorization', "Bearer #{token}") + post_json(server_endpoint, body) + + expect(last_response).to be_ok + + card_response = JSON.parse(last_response.body) + cards = card_response['cards'] + system_actions = card_response['systemActions'] + + expect(cards.length).to eq(0) + expect(system_actions.length).to eq(1) + expect(system_actions.first['resource']['id']).to eq('example') + + encounter_extension = system_actions.first['resource']['extension'] + coverage_extension = encounter_extension.first['extension'].first + + expect(coverage_extension['url']).to eq('coverage') + expect(coverage_extension['valueReference']['reference']).to eq("Coverage/#{crd_coverage['id']}") + expect(order_request).to have_been_made + end + + it 'successfully returns all supported cards when all selected_response_type options are selected' do + order_request = stub_request(:get, + "#{client_fhir_server}/ServiceRequest/example") + .with( + headers: { 'Authorization' => 'Bearer SAMPLE_TOKEN' } + ) + .to_return(status: 200, body: crd_order.to_json) + + allow(test).to receive(:suite).and_return(suite) + + token = jwt_helper.build( + aud: order_dispatch_url, + iss: example_client_url, + jku: "#{example_client_url}/jwks.json", + encryption_method: 'RS384' + ) + + run(test, iss: example_client_url, order_dispatch_selected_response_types: order_dispatch_selected_response_types + + ['request_form_completion', 'create_update_coverage_info', 'launch_smart_app', + 'propose_alternate_request', 'companions_prerequisites']) + + body['prefetch'] = { 'coverage' => crd_coverage } + header('Authorization', "Bearer #{token}") + post_json(server_endpoint, body) + + expect(last_response).to be_ok + + card_response = JSON.parse(last_response.body) + cards = card_response['cards'] + system_actions = card_response['systemActions'] + + expect(cards.length).to eq(7) + + expect(cards.first['summary']).to eq('Order Dispatch Request Form Completion Card') + expect(cards[1]['summary']).to eq('Order Dispatch Launch SMART Application Card') + expect(cards[2]['summary']).to eq('Order Dispatch External Reference Card') + expect(cards[3]['summary']).to eq('Order Dispatch Additional Orders As Companions/Prerequisites Card') + expect(cards[4]['summary']).to eq('Order Dispatch Propose Alternate Request Card') + expect(cards[5]['summary']).to eq('Order Dispatch Create/Update Coverage Information Card') + expect(cards[6]['summary']).to eq('Order Dispatch Instructions Card') + + expect(system_actions.length).to eq(1) + expect(system_actions.first['resource']['id']).to eq('example') + + order_extension = system_actions.first['resource']['extension'] + coverage_extension = order_extension.first['extension'].first + + expect(coverage_extension['url']).to eq('coverage') + expect(coverage_extension['valueReference']['reference']).to eq("Coverage/#{crd_coverage['id']}") + expect(order_request).to have_been_made.times(2) + end +end diff --git a/spec/davinci_crd_test_kit/order_select_receive_request_test_spec.rb b/spec/davinci_crd_test_kit/order_select_receive_request_test_spec.rb new file mode 100644 index 0000000..f93a14d --- /dev/null +++ b/spec/davinci_crd_test_kit/order_select_receive_request_test_spec.rb @@ -0,0 +1,275 @@ +require_relative '../../lib/davinci_crd_test_kit/client_tests/order_select_receive_request_test' +require_relative '../request_helper' + +RSpec.describe DaVinciCRDTestKit::OrderSelectReceiveRequestTest do + include Rack::Test::Methods + include RequestHelpers + + let(:suite) { Inferno::Repositories::TestSuites.new.find('crd_client') } + let(:test) { Inferno::Repositories::Tests.new.find('crd_order_select_request') } + let(:session_data_repo) { Inferno::Repositories::SessionData.new } + let(:results_repo) { Inferno::Repositories::Results.new } + let(:test_session) { repo_create(:test_session, test_suite_id: 'crd_client') } + let(:jwt_helper) { Class.new(DaVinciCRDTestKit::JwtHelper) } + + let(:example_client_url) { 'https://cds.example.org' } + let(:base_url) { "#{Inferno::Application['base_url']}/custom/crd_client" } + let(:order_select_url) { "#{base_url}/cds-services/order-select-service" } + let(:client_fhir_server) { 'https://example/r4' } + let(:patient_id) { 'example' } + let(:order_select_selected_response_types) { ['instructions', 'coverage_information', 'external_reference'] } + + let(:server_endpoint) { '/custom/crd_client/cds-services/order-select-service' } + let(:body) do + JSON.parse(File.read(File.join( + __dir__, '..', 'fixtures', 'order_select_hook_request.json' + ))) + end + + let(:crd_coverage) do + JSON.parse(File.read(File.join( + __dir__, '..', 'fixtures', 'crd_coverage_example.json' + ))) + end + let(:crd_coverage_bundle) do + bundle = FHIR::Bundle.new(type: 'searchset') + bundle.entry.append(FHIR::Bundle::Entry.new( + fullUrl: 'https://example.com/base/Coverage/coverage_example', + resource: FHIR.from_contents(crd_coverage.to_json) + )) + bundle + end + + def run(runnable, inputs = {}) + test_run_params = { test_session_id: test_session.id }.merge(runnable.reference_hash) + test_run = Inferno::Repositories::TestRuns.new.create(test_run_params) + inputs.each do |name, value| + session_data_repo.save( + test_session_id: test_session.id, + name:, + value:, + type: runnable.config.input_type(name) + ) + end + Inferno::TestRunner.new(test_session:, test_run:).run(runnable) + end + + it 'passes and responds 200 if request sent to the provided URL and jwt `iss` claim matches the given`iss`' do + allow(test).to receive(:suite).and_return(suite) + + token = jwt_helper.build( + aud: order_select_url, + iss: example_client_url, + jku: "#{example_client_url}/jwks.json", + encryption_method: 'RS384' + ) + + result = run(test, iss: example_client_url, order_select_selected_response_types:) + + expect(result.result).to eq('wait') + + body['prefetch'] = { 'coverage' => crd_coverage } + header('Authorization', "Bearer #{token}") + post_json(server_endpoint, body) + + expect(last_response).to be_ok + result = results_repo.find(result.id) + expect(result.result).to eq('pass') + end + + it 'returns cards and systemActions and uses client information to build request' do + allow(test).to receive(:suite).and_return(suite) + + token = jwt_helper.build( + aud: order_select_url, + iss: example_client_url, + jku: "#{example_client_url}/jwks.json", + encryption_method: 'RS384' + ) + + run(test, iss: example_client_url, order_select_selected_response_types:) + + body['prefetch'] = { 'coverage' => crd_coverage } + header('Authorization', "Bearer #{token}") + post_json(server_endpoint, body) + + expect(last_response).to be_ok + + card_response = JSON.parse(last_response.body) + cards = card_response['cards'] + system_actions = card_response['systemActions'] + + expect(cards.length).to eq(2) + expect(system_actions.length).to eq(2) + expect(system_actions.first['resource']['id']).to eq('pureeddiet-simple') + expect(system_actions.last['resource']['id']).to eq('smart-MedicationRequest-103') + + order_extension = system_actions.first['resource']['extension'] + coverage_extension = order_extension.first['extension'].first + + expect(coverage_extension['url']).to eq('coverage') + expect(coverage_extension['valueReference']['reference']).to eq("Coverage/#{crd_coverage['id']}") + end + + it 'queries the client\'s FHIR server if coverage is not present in the prefetch' do + coverage_search_request = stub_request(:get, + "#{client_fhir_server}/Coverage?patient=#{patient_id}&status=active") + .with( + headers: { 'Authorization' => 'Bearer SAMPLE_TOKEN' } + ) + .to_return(status: 200, body: crd_coverage_bundle.to_json) + + allow(test).to receive(:suite).and_return(suite) + + token = jwt_helper.build( + aud: order_select_url, + iss: example_client_url, + jku: "#{example_client_url}/jwks.json", + encryption_method: 'RS384' + ) + + run(test, iss: example_client_url, order_select_selected_response_types:) + + header('Authorization', "Bearer #{token}") + post_json(server_endpoint, body) + + expect(last_response).to be_ok + + card_response = JSON.parse(last_response.body) + cards = card_response['cards'] + system_actions = card_response['systemActions'] + + expect(cards.length).to eq(2) + expect(system_actions.length).to eq(2) + expect(system_actions.first['resource']['id']).to eq('pureeddiet-simple') + expect(system_actions.last['resource']['id']).to eq('smart-MedicationRequest-103') + + order_extension = system_actions.first['resource']['extension'] + coverage_extension = order_extension.first['extension'].first + + expect(coverage_extension['url']).to eq('coverage') + expect(coverage_extension['valueReference']['reference']).to eq("Coverage/#{crd_coverage['id']}") + expect(coverage_search_request).to have_been_made + end + + it 'waits and responds with 500 if request sent to the provided URL and jwt `iss` claim mismatches the given `iss`' do + allow(test).to receive(:suite).and_return(suite) + + token = jwt_helper.build( + aud: order_select_url, + iss: example_client_url, + jku: "#{example_client_url}/jwks.json", + encryption_method: 'RS384' + ) + + result = run(test, iss: 'example.com', order_select_selected_response_types:) + + expect(result.result).to eq('wait') + + body['prefetch'] = { 'coverage' => crd_coverage } + header('Authorization', "Bearer #{token}") + post_json(server_endpoint, body.to_json) + + expect(last_response).to be_server_error + expect(last_response.body).to match(/find test run with identifier/) + result = results_repo.find(result.id) + expect(result.result).to eq('wait') + end + + it 'waits and responds with 500 if request sent to the provided URL contains the wrong hook name' do + allow(test).to receive(:suite).and_return(suite) + + token = jwt_helper.build( + aud: order_select_url, + iss: example_client_url, + jku: "#{example_client_url}/jwks.json", + encryption_method: 'RS384' + ) + + result = run(test, iss: 'example.com', order_select_selected_response_types:) + + expect(result.result).to eq('wait') + + body['prefetch'] = { 'coverage' => crd_coverage } + body['hook'] = 'incorrect-hook' + header('Authorization', "Bearer #{token}") + post_json(server_endpoint, body.to_json) + + expect(last_response).to be_server_error + expect(last_response.body).to match(/find test run with identifier/) + result = results_repo.find(result.id) + expect(result.result).to eq('wait') + end + + it 'returns default cards when no order_select_selected_response_types selected' do + allow(test).to receive(:suite).and_return(suite) + + token = jwt_helper.build( + aud: order_select_url, + iss: example_client_url, + jku: "#{example_client_url}/jwks.json", + encryption_method: 'RS384' + ) + + run(test, iss: example_client_url, order_select_selected_response_types: []) + + body['prefetch'] = { 'coverage' => crd_coverage } + header('Authorization', "Bearer #{token}") + post_json(server_endpoint, body) + + expect(last_response).to be_ok + + card_response = JSON.parse(last_response.body) + cards = card_response['cards'] + system_actions = card_response['systemActions'] + + expect(cards.length).to eq(1) + expect(system_actions).to be_nil + expect(cards.first['summary']).to eq('Order Select Instructions Card') + end + + it 'successfully returns all supported cards when all selected_response_type options are selected' do + allow(test).to receive(:suite).and_return(suite) + + token = jwt_helper.build( + aud: order_select_url, + iss: example_client_url, + jku: "#{example_client_url}/jwks.json", + encryption_method: 'RS384' + ) + + run(test, iss: example_client_url, order_select_selected_response_types: order_select_selected_response_types + + ['request_form_completion', 'create_update_coverage_info', 'launch_smart_app', + 'propose_alternate_request', 'companions_prerequisites']) + + body['prefetch'] = { 'coverage' => crd_coverage } + header('Authorization', "Bearer #{token}") + post_json(server_endpoint, body) + + expect(last_response).to be_ok + + card_response = JSON.parse(last_response.body) + cards = card_response['cards'] + system_actions = card_response['systemActions'] + + expect(cards.length).to eq(7) + + expect(cards.first['summary']).to eq('Order Select Request Form Completion Card') + expect(cards[1]['summary']).to eq('Order Select Launch SMART Application Card') + expect(cards[2]['summary']).to eq('Order Select External Reference Card') + expect(cards[3]['summary']).to eq('Order Select Additional Orders As Companions/Prerequisites Card') + expect(cards[4]['summary']).to eq('Order Select Propose Alternate Request Card') + expect(cards[5]['summary']).to eq('Order Select Create/Update Coverage Information Card') + expect(cards[6]['summary']).to eq('Order Select Instructions Card') + + expect(system_actions.length).to eq(2) + expect(system_actions.first['resource']['id']).to eq('pureeddiet-simple') + expect(system_actions.last['resource']['id']).to eq('smart-MedicationRequest-103') + + order_extension = system_actions.first['resource']['extension'] + coverage_extension = order_extension.first['extension'].first + + expect(coverage_extension['url']).to eq('coverage') + expect(coverage_extension['valueReference']['reference']).to eq("Coverage/#{crd_coverage['id']}") + end +end diff --git a/spec/davinci_crd_test_kit/order_sign_receive_request_test_spec.rb b/spec/davinci_crd_test_kit/order_sign_receive_request_test_spec.rb new file mode 100644 index 0000000..1efc2bd --- /dev/null +++ b/spec/davinci_crd_test_kit/order_sign_receive_request_test_spec.rb @@ -0,0 +1,282 @@ +require_relative '../../lib/davinci_crd_test_kit/client_tests/order_sign_receive_request_test' +require_relative '../request_helper' + +RSpec.describe DaVinciCRDTestKit::OrderSignReceiveRequestTest do + include Rack::Test::Methods + include RequestHelpers + + let(:suite) { Inferno::Repositories::TestSuites.new.find('crd_client') } + let(:test) { Inferno::Repositories::Tests.new.find('crd_order_sign_request') } + let(:session_data_repo) { Inferno::Repositories::SessionData.new } + let(:results_repo) { Inferno::Repositories::Results.new } + let(:test_session) { repo_create(:test_session, test_suite_id: 'crd_client') } + let(:jwt_helper) { Class.new(DaVinciCRDTestKit::JwtHelper) } + + let(:example_client_url) { 'https://cds.example.org' } + let(:base_url) { "#{Inferno::Application['base_url']}/custom/crd_client" } + let(:order_sign_url) { "#{base_url}/cds-services/order-sign-service" } + let(:client_fhir_server) { 'https://example/r4' } + let(:patient_id) { 'example' } + let(:order_sign_selected_response_types) { ['instructions', 'coverage_information', 'external_reference'] } + + let(:server_endpoint) { '/custom/crd_client/cds-services/order-sign-service' } + let(:body) do + JSON.parse(File.read(File.join( + __dir__, '..', 'fixtures', 'order_sign_hook_request.json' + ))) + end + + let(:crd_coverage) do + JSON.parse(File.read(File.join( + __dir__, '..', 'fixtures', 'crd_coverage_example.json' + ))) + end + let(:crd_coverage_bundle) do + bundle = FHIR::Bundle.new(type: 'searchset') + bundle.entry.append(FHIR::Bundle::Entry.new( + fullUrl: 'https://example.com/base/Coverage/coverage_example', + resource: FHIR.from_contents(crd_coverage.to_json) + )) + bundle + end + + def run(runnable, inputs = {}) + test_run_params = { test_session_id: test_session.id }.merge(runnable.reference_hash) + test_run = Inferno::Repositories::TestRuns.new.create(test_run_params) + inputs.each do |name, value| + session_data_repo.save( + test_session_id: test_session.id, + name:, + value:, + type: runnable.config.input_type(name) + ) + end + Inferno::TestRunner.new(test_session:, test_run:).run(runnable) + end + + it 'passes and responds 200 if request sent to the provided URL and jwt `iss` claim matches the given`iss`' do + allow(test).to receive(:suite).and_return(suite) + + token = jwt_helper.build( + aud: order_sign_url, + iss: example_client_url, + jku: "#{example_client_url}/jwks.json", + encryption_method: 'RS384' + ) + + result = run(test, iss: example_client_url, order_sign_selected_response_types:) + + expect(result.result).to eq('wait') + + body['prefetch'] = { 'coverage' => crd_coverage } + header('Authorization', "Bearer #{token}") + post_json(server_endpoint, body) + + expect(last_response).to be_ok + result = results_repo.find(result.id) + expect(result.result).to eq('pass') + end + + it 'returns cards and systemActions and uses client information to build request' do + allow(test).to receive(:suite).and_return(suite) + + token = jwt_helper.build( + aud: order_sign_url, + iss: example_client_url, + jku: "#{example_client_url}/jwks.json", + encryption_method: 'RS384' + ) + + run(test, iss: example_client_url, order_sign_selected_response_types:) + + body['prefetch'] = { 'coverage' => crd_coverage } + header('Authorization', "Bearer #{token}") + post_json(server_endpoint, body) + + expect(last_response).to be_ok + + card_response = JSON.parse(last_response.body) + cards = card_response['cards'] + system_actions = card_response['systemActions'] + + expect(cards.length).to eq(2) + expect(system_actions.length).to eq(2) + expect(system_actions.first['resource']['id']).to eq('pureeddiet-simple') + expect(system_actions.last['resource']['id']).to eq('smart-MedicationRequest-103') + + order_extension = system_actions.first['resource']['extension'] + coverage_extension = order_extension.first['extension'].first + + expect(coverage_extension['url']).to eq('coverage') + expect(coverage_extension['valueReference']['reference']).to eq("Coverage/#{crd_coverage['id']}") + end + + it 'queries the client\'s FHIR server if coverage is not present in the prefetch' do + coverage_search_request = stub_request(:get, + "#{client_fhir_server}/Coverage?patient=#{patient_id}&status=active") + .with( + headers: { 'Authorization' => 'Bearer SAMPLE_TOKEN' } + ) + .to_return(status: 200, body: crd_coverage_bundle.to_json) + + allow(test).to receive(:suite).and_return(suite) + + token = jwt_helper.build( + aud: order_sign_url, + iss: example_client_url, + jku: "#{example_client_url}/jwks.json", + encryption_method: 'RS384' + ) + + run(test, iss: example_client_url, order_sign_selected_response_types:) + + header('Authorization', "Bearer #{token}") + post_json(server_endpoint, body) + + expect(last_response).to be_ok + + card_response = JSON.parse(last_response.body) + cards = card_response['cards'] + system_actions = card_response['systemActions'] + + expect(cards.length).to eq(2) + expect(system_actions.length).to eq(2) + expect(system_actions.first['resource']['id']).to eq('pureeddiet-simple') + expect(system_actions.last['resource']['id']).to eq('smart-MedicationRequest-103') + + order_extension = system_actions.first['resource']['extension'] + coverage_extension = order_extension.first['extension'].first + + expect(coverage_extension['url']).to eq('coverage') + expect(coverage_extension['valueReference']['reference']).to eq("Coverage/#{crd_coverage['id']}") + expect(coverage_search_request).to have_been_made + end + + it 'waits and responds with 500 if request sent to the provided URL and jwt `iss` claim mismatches the given `iss`' do + allow(test).to receive(:suite).and_return(suite) + + token = jwt_helper.build( + aud: order_sign_url, + iss: example_client_url, + jku: "#{example_client_url}/jwks.json", + encryption_method: 'RS384' + ) + + result = run(test, iss: 'example.com', order_sign_selected_response_types:) + + expect(result.result).to eq('wait') + + body['prefetch'] = { 'coverage' => crd_coverage } + header('Authorization', "Bearer #{token}") + post_json(server_endpoint, body.to_json) + + expect(last_response).to be_server_error + expect(last_response.body).to match(/find test run with identifier/) + result = results_repo.find(result.id) + expect(result.result).to eq('wait') + end + + it 'waits and responds with 500 if request sent to the provided URL contains the wrong hook name' do + allow(test).to receive(:suite).and_return(suite) + + token = jwt_helper.build( + aud: order_sign_url, + iss: example_client_url, + jku: "#{example_client_url}/jwks.json", + encryption_method: 'RS384' + ) + + result = run(test, iss: 'example.com', order_sign_selected_response_types:) + + expect(result.result).to eq('wait') + + body['prefetch'] = { 'coverage' => crd_coverage } + body['hook'] = 'incorrect-hook' + header('Authorization', "Bearer #{token}") + post_json(server_endpoint, body.to_json) + + expect(last_response).to be_server_error + expect(last_response.body).to match(/find test run with identifier/) + result = results_repo.find(result.id) + expect(result.result).to eq('wait') + end + + it 'returns default cards when no order_sign_selected_response_types selected' do + allow(test).to receive(:suite).and_return(suite) + + token = jwt_helper.build( + aud: order_sign_url, + iss: example_client_url, + jku: "#{example_client_url}/jwks.json", + encryption_method: 'RS384' + ) + + run(test, iss: example_client_url, order_sign_selected_response_types: []) + + body['prefetch'] = { 'coverage' => crd_coverage } + header('Authorization', "Bearer #{token}") + post_json(server_endpoint, body) + + expect(last_response).to be_ok + + card_response = JSON.parse(last_response.body) + cards = card_response['cards'] + system_actions = card_response['systemActions'] + + expect(cards.length).to eq(0) + expect(system_actions.length).to eq(2) + expect(system_actions.first['resource']['id']).to eq('pureeddiet-simple') + expect(system_actions.last['resource']['id']).to eq('smart-MedicationRequest-103') + + order_extension = system_actions.first['resource']['extension'] + coverage_extension = order_extension.first['extension'].first + + expect(coverage_extension['url']).to eq('coverage') + expect(coverage_extension['valueReference']['reference']).to eq("Coverage/#{crd_coverage['id']}") + end + + it 'successfully returns all supported cards when all selected_response_type options are selected' do + allow(test).to receive(:suite).and_return(suite) + + token = jwt_helper.build( + aud: order_sign_url, + iss: example_client_url, + jku: "#{example_client_url}/jwks.json", + encryption_method: 'RS384' + ) + + run(test, iss: example_client_url, order_sign_selected_response_types: order_sign_selected_response_types + + ['request_form_completion', 'create_update_coverage_info', 'launch_smart_app', + 'propose_alternate_request', 'companions_prerequisites']) + + body['prefetch'] = { 'coverage' => crd_coverage } + header('Authorization', "Bearer #{token}") + post_json(server_endpoint, body) + + expect(last_response).to be_ok + + card_response = JSON.parse(last_response.body) + cards = card_response['cards'] + system_actions = card_response['systemActions'] + + expect(cards.length).to eq(7) + + expect(cards.first['summary']).to eq('Order Sign Request Form Completion Card') + expect(cards[1]['summary']).to eq('Order Sign Launch SMART Application Card') + expect(cards[2]['summary']).to eq('Order Sign External Reference Card') + expect(cards[3]['summary']).to eq('Order Sign Additional Orders As Companions/Prerequisites Card') + expect(cards[4]['summary']).to eq('Order Sign Propose Alternate Request Card') + expect(cards[5]['summary']).to eq('Order Sign Create/Update Coverage Information Card') + expect(cards[6]['summary']).to eq('Order Sign Instructions Card') + + expect(system_actions.length).to eq(2) + expect(system_actions.first['resource']['id']).to eq('pureeddiet-simple') + expect(system_actions.last['resource']['id']).to eq('smart-MedicationRequest-103') + + order_extension = system_actions.first['resource']['extension'] + coverage_extension = order_extension.first['extension'].first + + expect(coverage_extension['url']).to eq('coverage') + expect(coverage_extension['valueReference']['reference']).to eq("Coverage/#{crd_coverage['id']}") + end +end diff --git a/spec/davinci_crd_test_kit/propose_alternate_request_card_validation_test_spec.rb b/spec/davinci_crd_test_kit/propose_alternate_request_card_validation_test_spec.rb new file mode 100644 index 0000000..6393126 --- /dev/null +++ b/spec/davinci_crd_test_kit/propose_alternate_request_card_validation_test_spec.rb @@ -0,0 +1,97 @@ +RSpec.describe DaVinciCRDTestKit::ProposeAlternateRequestCardValidationTest do + let(:runnable) { Inferno::Repositories::Tests.new.find('crd_propose_alternate_request_card_validation') } + let(:session_data_repo) { Inferno::Repositories::SessionData.new } + let(:results_repo) { Inferno::Repositories::Results.new } + let(:test_session) { repo_create(:test_session, test_suite_id: 'crd_server') } + let(:order_select_context) do + json = File.read(File.join(__dir__, '..', 'fixtures', 'order_select_context.json')) + JSON.parse(json) + end + let(:valid_cards) do + json = File.read(File.join(__dir__, '..', 'fixtures', 'valid_cards.json')) + JSON.parse(json) + end + let(:cards_with_suggestions) { valid_cards.filter { |card| card['suggestions'].present? } } + + def run(runnable, inputs = {}) + test_run_params = { test_session_id: test_session.id }.merge(runnable.reference_hash) + test_run = Inferno::Repositories::TestRuns.new.create(test_run_params) + inputs.each do |name, value| + session_data_repo.save( + test_session_id: test_session.id, + name:, + value:, + type: runnable.config.input_type(name) + ) + end + Inferno::TestRunner.new(test_session:, test_run:).run(runnable) + end + + def entity_result_message + results_repo.current_results_for_test_session_and_runnables(test_session.id, [runnable]) + .first + .messages + .first + end + + before do + allow_any_instance_of(runnable).to receive(:resource_is_valid?).and_return(true) + end + + it 'passes if valid propose alternate request cards are received' do + result = run(runnable, valid_cards_with_suggestions: cards_with_suggestions.to_json, + contexts: [order_select_context].to_json) + + expect(result.result).to eq('pass') + end + + it 'skips if valid_cards_with_suggestions not present' do + result = run(runnable, contexts: [order_select_context].to_json) + expect(result.result).to eq('skip') + expect(result.result_message).to match(/'valid_cards_with_suggestions' is nil, skipping test/) + end + + it 'skips if contexts not present' do + result = run(runnable, valid_cards_with_suggestions: cards_with_suggestions.to_json) + expect(result.result).to eq('skip') + expect(result.result_message).to match(/'contexts' is nil, skipping test/) + end + + it 'fails if valid_cards_with_suggestions is not valid json' do + result = run(runnable, valid_cards_with_suggestions: '[[', contexts: [order_select_context].to_json) + expect(result.result).to eq('fail') + expect(result.result_message).to match(/Invalid JSON/) + end + + it 'fails if contexts is not json' do + result = run(runnable, valid_cards_with_suggestions: cards_with_suggestions.to_json, contexts: '[[') + expect(result.result).to eq('fail') + expect(result.result_message).to match(/Invalid JSON/) + end + + it 'skips if no propose alternate request card present' do + result = run(runnable, valid_cards_with_suggestions: [].to_json, contexts: [order_select_context].to_json) + expect(result.result).to eq('skip') + expect(result.result_message).to match(/does not contain a Propose Alternate Request card/) + end + + it 'fails if the order being deleted is not in draftOrders' do + dup_cards = cards_with_suggestions.deep_dup + action = dup_cards.first['suggestions'].first['actions'].find { |act| act['type'] == 'delete' } + action['resourceId'] << 'ServiceRequest/example' + + result = run(runnable, valid_cards_with_suggestions: dup_cards.to_json, contexts: [order_select_context].to_json) + expect(result.result).to eq('fail') + expect(entity_result_message.message).to match(/must reference FHIR resource from the `draftOrders`/) + end + + it 'fails if there is no create action for the order being deleted' do + dup_cards = cards_with_suggestions.deep_dup + action = dup_cards.first['suggestions'].first['actions'].find { |act| act['type'] == 'create' } + action['resource']['resourceType'] = 'ServiceRequest' + + result = run(runnable, valid_cards_with_suggestions: dup_cards.to_json, contexts: [order_select_context].to_json) + expect(result.result).to eq('fail') + expect(entity_result_message.message).to match(/There's no `create` action/) + end +end diff --git a/spec/davinci_crd_test_kit/retrieve_jwks_test_spec.rb b/spec/davinci_crd_test_kit/retrieve_jwks_test_spec.rb new file mode 100644 index 0000000..4ce9467 --- /dev/null +++ b/spec/davinci_crd_test_kit/retrieve_jwks_test_spec.rb @@ -0,0 +1,130 @@ +require_relative '../../lib/davinci_crd_test_kit/client_tests/retrieve_jwks_test' + +RSpec.describe DaVinciCRDTestKit::RetrieveJWKSTest do + let(:test) { Inferno::Repositories::Tests.new.find('crd_retrieve_jwks') } + let(:session_data_repo) { Inferno::Repositories::SessionData.new } + let(:test_session) { repo_create(:test_session, test_suite_id: 'crd_client') } + + let(:example_client_url) { 'https://cds.example.org' } + let(:base_url) { "#{Inferno::Application['base_url']}/custom/crd_client" } + let(:jwks_hash) { JSON.parse(DaVinciCRDTestKit::JWKS.jwks_json) } + let(:jwk) { jwks_hash['keys'].find { |key| key['alg'] == 'RS384' } } + let(:token_header) do + { + alg: 'RS384', + kid: jwk['kid'], + typ: 'JWT', + jku: "#{example_client_url}/jwks.json" + } + end + + let(:jwks_hash_no_keys) { { keys: [] } } + let(:invalid_jwks_hash) do + { + keys: jwks_hash['keys'].each { |key| key['kid'] = 1234 } + } + end + let(:jwks_hash_dup_kids) do + { + keys: jwks_hash['keys'].each { |key| key['kid'] = jwk['kid'] } + } + end + let(:jwks_hash_no_kids) { { keys: jwks_hash['keys'].map { |key| key.except('kid') } } } + + def run(runnable, inputs = {}) + test_run_params = { test_session_id: test_session.id }.merge(runnable.reference_hash) + test_run = Inferno::Repositories::TestRuns.new.create(test_run_params) + inputs.each do |name, value| + session_data_repo.save( + test_session_id: test_session.id, + name:, + value:, + type: runnable.config.input_type(name) + ) + end + Inferno::TestRunner.new(test_session:, test_run:).run(runnable) + end + + it 'passes if it receives a valid JWT Authorization header with jku field populated' do + jwks_request = stub_request(:get, "#{example_client_url}/jwks.json") + .to_return(status: 200, body: jwks_hash.to_json) + + result = run(test, auth_token_header_json: token_header.to_json) + expect(result.result).to eq('pass') + expect(jwks_request).to have_been_made + end + + it 'passes if it receives a valid jwk_set input' do + token_header_no_jku = token_header.except(:jku) + + result = run(test, auth_token_header_json: token_header_no_jku.to_json, jwk_set: jwks_hash.to_json) + expect(result.result).to eq('pass') + end + + it 'skips if jku field is not set, and no jwk_set is provided' do + token_header_no_jku = token_header.except(:jku) + + result = run(test, auth_token_header_json: token_header_no_jku.to_json) + expect(result.result).to eq('skip') + expect(result.result_message).to match("JWK Set must be inputted if Client's JWK Set is not available") + end + + it 'fails if it receives non 200 response' do + jwks_request = stub_request(:get, "#{example_client_url}/jwks.json") + .to_return(status: 404, body: jwks_hash.to_json) + + result = run(test, auth_token_header_json: token_header.to_json) + expect(result.result).to eq('fail') + expect(result.result_message).to eq('Unexpected response status: expected 200, but received 404') + expect(jwks_request).to have_been_made + end + + it 'fails if jwks returned is not a valid json' do + jwks_request = stub_request(:get, "#{example_client_url}/jwks.json") + .to_return(status: 200, body: nil) + + result = run(test, auth_token_header_json: token_header.to_json) + expect(result.result).to eq('fail') + expect(result.result_message).to eq('Invalid JSON. ') + expect(jwks_request).to have_been_made + end + + it 'fails if jwks returned is not an array' do + jwks_request = stub_request(:get, "#{example_client_url}/jwks.json") + .to_return(status: 200, body: jwk.to_json) + + result = run(test, auth_token_header_json: token_header.to_json) + expect(result.result).to eq('fail') + expect(result.result_message).to eq('JWKS `keys` field must be an array') + expect(jwks_request).to have_been_made + end + + it 'fails if jwks returned has no keys' do + jwks_request = stub_request(:get, "#{example_client_url}/jwks.json") + .to_return(status: 200, body: jwks_hash_no_keys.to_json) + + result = run(test, auth_token_header_json: token_header.to_json) + expect(result.result).to eq('fail') + expect(result.result_message).to eq('The JWK set returned contains no public keys') + expect(jwks_request).to have_been_made + end + + it 'fails if jwks returned does not contain kid field' do + jwks_request = stub_request(:get, "#{example_client_url}/jwks.json") + .to_return(status: 200, body: jwks_hash_no_kids.to_json) + + result = run(test, auth_token_header_json: token_header.to_json) + expect(result.result).to eq('fail') + expect(result.result_message).to eq('`kid` field must be present in each key if JWKS contains multiple keys') + expect(jwks_request).to have_been_made + end + + it 'fails if jwks returned contains duplicate kid fields' do + jwks_request = stub_request(:get, "#{example_client_url}/jwks.json") + .to_return(status: 200, body: jwks_hash_dup_kids.to_json) + result = run(test, auth_token_header_json: token_header.to_json) + expect(result.result).to eq('fail') + expect(result.result_message).to eq("`kid` must be unique within the client' JWK Set.") + expect(jwks_request).to have_been_made + end +end diff --git a/spec/davinci_crd_test_kit/routes/cds_services_discovery_handler_spec.rb b/spec/davinci_crd_test_kit/routes/cds_services_discovery_handler_spec.rb new file mode 100644 index 0000000..21b58f2 --- /dev/null +++ b/spec/davinci_crd_test_kit/routes/cds_services_discovery_handler_spec.rb @@ -0,0 +1,26 @@ +require 'request_helper' + +RSpec.describe DaVinciCRDTestKit::Routes::CDSServicesDiscoveryHandler do + let(:router) { Inferno::Web::Router } + + describe 'GET /cds-services' do + it 'returns JSON with required fields' do + get '/custom/crd_client/cds-services' + + expect(last_response).to be_ok + expect(last_response.headers['Content-Type']).to eq('application/json') + + response_json = JSON.parse(last_response.body) + + expect(response_json).to include('services') + expect(response_json['services']).to be_an(Array) + + services = response_json['services'] + expect(services).to be_an(Array) + + services.all? do |service| + expect(service).to include('hook', 'description', 'id') + end + end + end +end diff --git a/spec/davinci_crd_test_kit/server_discovery_group_spec.rb b/spec/davinci_crd_test_kit/server_discovery_group_spec.rb new file mode 100644 index 0000000..e13feb8 --- /dev/null +++ b/spec/davinci_crd_test_kit/server_discovery_group_spec.rb @@ -0,0 +1,156 @@ +RSpec.describe DaVinciCRDTestKit::ServerDiscoveryGroup do + let(:suite) { Inferno::Repositories::TestSuites.new.find('crd_server') } + let(:group) { Inferno::Repositories::TestGroups.new.find('crd_server_discovery_group') } + let(:session_data_repo) { Inferno::Repositories::SessionData.new } + let(:test_session) { repo_create(:test_session, test_suite_id: 'crd_server') } + let(:base_url) { 'http://example.com' } + let(:discovery_url) { 'http://example.com/cds-services' } + let(:cds_services) do + { + 'services' => [ + { + 'hook' => 'appointment-book', + 'title' => 'Appointment Booking CDS Service', + 'description' => 'An example of a CDS Service that is invoked when user of a CRD Client books an appointment', + 'id' => 'appointment-book-service', + 'prefetch' => { + 'user' => '{{context.userId}}', + 'patient' => 'Patient/{{context.patientId}}' + } + } + ] + } + end + + def run(runnable, inputs = {}) + test_run_params = { test_session_id: test_session.id }.merge(runnable.reference_hash) + test_run = Inferno::Repositories::TestRuns.new.create(test_run_params) + inputs.each do |name, value| + session_data_repo.save( + test_session_id: test_session.id, + name:, + value:, + type: runnable.config.input_type(name) + ) + end + Inferno::TestRunner.new(test_session:, test_run:).run(runnable) + end + + describe 'discovery endpoint test' do + let(:runnable) { group.tests[1] } + let(:authentication_required) { 'no' } + let(:encryption_method) { 'ES384' } + + it 'passes when a 200 response is received' do + stub_request(:get, discovery_url) + .to_return(status: 200, body: cds_services.to_json) + result = run(runnable, base_url:, authentication_required:, encryption_method:) + + expect(result.result).to eq('pass') + end + + it 'persists cds_services output' do + stub_request(:get, discovery_url) + .to_return(status: 200, body: cds_services.to_json) + run(runnable, base_url:, authentication_required:, encryption_method:) + + expect(session_data_repo.load(test_session_id: test_session.id, name: 'cds_services')) + .to eq(cds_services.to_json) + end + + it 'fails when a non-200 response is received' do + stub_request(:get, discovery_url) + .to_return(status: 201, body: cds_services.to_json) + result = run(runnable, base_url:, authentication_required:, encryption_method:) + expect(result.result).to eq('fail') + expect(result.result_message).to match(/Unexpected response status:/) + end + + it 'fails when the response body is an invalid json' do + stub_request(:get, discovery_url) + .to_return(status: 200, body: 'wd') + result = run(runnable, base_url:, authentication_required:, encryption_method:) + + expect(result.result).to eq('fail') + expect(result.result_message).to match(/Invalid JSON/) + end + end + + describe 'discovery services validation test' do + let(:runnable) { group.tests[2] } + + it 'passes when the all cds services contain all required fields' do + result = run(runnable, cds_services: cds_services.to_json) + + expect(result.result).to eq('pass') + end + + it 'fails if CDS services object does not contain a "services" attribute' do + result = run(runnable, cds_services: {}.to_json) + + expect(result.result).to eq('fail') + expect(result.result_message).to eq('Discovery response did not contain `services`') + end + + it 'fails if "services" attribute of CDS services object is not an array' do + result = run(runnable, cds_services: { services: {} }.to_json) + + expect(result.result).to eq('fail') + expect(result.result_message).to eq('Services field of the CDS Discovery response object is not an array.') + end + + it 'fails if a required field is missing from at least one service' do + invalid_services = { + 'services' => [ + { + 'title' => 'Appointment Booking CDS Service', + 'id' => 'appointment-book-service', + 'prefetch' => { + 'user' => '{{context.userId}}', + 'patient' => 'Patient/{{context.patientId}}' + } + } + ] + } + result = run(runnable, cds_services: invalid_services.to_json) + + expect(result.result).to eq('fail') + expect(result.result_message).to match(/did not contain required field/) + end + + it 'fails if a required service field is present but does not have the correct data type' do + invalid_services = { + 'services' => [ + { + 'hook' => ['appointment-book'], + 'title' => 'Appointment Booking CDS Service', + 'description' => 'An example of a CDS Service that is invoked when user of a CRD Client books an appt.', + 'id' => 'appointment-book-service', + 'prefetch' => { + 'user' => '{{context.userId}}', + 'patient' => 'Patient/{{context.patientId}}' + } + } + ] + } + result = run(runnable, cds_services: invalid_services.to_json) + + expect(result.result).to eq('fail') + expect(result.result_message).to match(/is not of type/) + end + + it 'skips if "cds_services" input is missing' do + result = run(runnable, cds_services: nil) + + expect(result.result).to eq('skip') + expect(result.result_message).to match(/'cds_services' is nil, skipping test/) + end + + it 'skips if "services" attribute of cds services object is an empty array' do + result = run(runnable, cds_services: { services: [] }.to_json) + + expect(result.result).to eq('skip') + expect(result.result_message).to eq('Server hosts no CDS Services.') + end + end +end diff --git a/spec/davinci_crd_test_kit/service_call_test_spec.rb b/spec/davinci_crd_test_kit/service_call_test_spec.rb new file mode 100644 index 0000000..5a14415 --- /dev/null +++ b/spec/davinci_crd_test_kit/service_call_test_spec.rb @@ -0,0 +1,100 @@ +RSpec.describe DaVinciCRDTestKit::ServiceCallTest do + let(:session_data_repo) { Inferno::Repositories::SessionData.new } + let(:results_repo) { Inferno::Repositories::Results.new } + let(:test_session) { repo_create(:test_session, test_suite_id: 'crd_server') } + let(:runnable) do + Class.new(DaVinciCRDTestKit::ServiceCallTest) do + input :inferno_base_url + end + end + let(:base_url) { 'http://example.com' } + let(:discovery_url) { 'http://example.com/cds-services' } + let(:inferno_base_url) { 'http://inferno.com' } + let(:service_ids) { 'service_ids' } + let(:service_request_body) do + json = File.read(File.join(__dir__, '..', 'fixtures', 'appointment_book_hook_request.json')) + JSON.parse(json) + end + let(:service_request_bodies) { [service_request_body].to_json } + + let(:encryption_method) { 'ES384' } + + def run(runnable, inputs = {}) + test_run_params = { test_session_id: test_session.id }.merge(runnable.reference_hash) + test_run = Inferno::Repositories::TestRuns.new.create(test_run_params) + inputs.each do |name, value| + session_data_repo.save( + test_session_id: test_session.id, + name:, + value:, + type: runnable.config.input_type(name) + ) + end + Inferno::TestRunner.new(test_session:, test_run:).run(runnable) + end + + before do + allow_any_instance_of(runnable).to receive(:hook_name).and_return('appointment-book') + end + + it 'passes when the server returns a 200 HTTP response' do + stub_request(:post, "#{discovery_url}/#{service_ids}") + .with( + body: service_request_body + ) + .to_return(status: 200, body: {}.to_json) + + result = run(runnable, base_url:, inferno_base_url:, service_ids:, encryption_method:, service_request_bodies:) + expect(result.result).to eq('pass') + end + + it 'skips when the service_ids is not provided' do + result = run(runnable, base_url:, inferno_base_url:, service_ids: '', encryption_method:, + service_request_bodies:) + expect(result.result).to eq('skip') + expect(result.result_message).to match(/'service_ids' is nil, skipping test/) + end + + it 'skips when the service_request_bodies is not provided' do + result = run(runnable, base_url:, inferno_base_url:, service_ids:, encryption_method:) + expect(result.result).to eq('skip') + expect(result.result_message).to match(/Request body not provided/) + end + + it 'fails when the server does not return a 200 HTTP response' do + stub_request(:post, "#{discovery_url}/#{service_ids}") + .with( + body: service_request_body + ) + .to_return(status: 400, body: {}.to_json) + + result = run(runnable, base_url:, inferno_base_url:, service_ids:, encryption_method:, service_request_bodies:) + expect(result.result).to eq('fail') + expect(result.result_message).to match(/Unexpected response status: expected 200/) + end + + it 'fails when the request body is an invalid json' do + stub_request(:post, "#{discovery_url}/#{service_ids}") + .with( + body: 'body' + ) + .to_return(status: 200, body: {}.to_json) + + result = run(runnable, base_url:, inferno_base_url:, service_ids:, encryption_method:, + service_request_bodies: 'body') + expect(result.result).to eq('fail') + expect(result.result_message).to match(/Invalid JSON/) + end + + it 'fails when the response body is an invalid json' do + stub_request(:post, "#{discovery_url}/#{service_ids}") + .with( + body: service_request_body + ) + .to_return(status: 200, body: 'response') + + result = run(runnable, base_url:, inferno_base_url:, service_ids:, encryption_method:, service_request_bodies:) + expect(result.result).to eq('fail') + expect(result.result_message).to match(/Invalid JSON/) + end +end diff --git a/spec/davinci_crd_test_kit/service_request_context_validation_test_spec.rb b/spec/davinci_crd_test_kit/service_request_context_validation_test_spec.rb new file mode 100644 index 0000000..7fe545c --- /dev/null +++ b/spec/davinci_crd_test_kit/service_request_context_validation_test_spec.rb @@ -0,0 +1,431 @@ +RSpec.describe DaVinciCRDTestKit::ServiceRequestContextValidationTest do + let(:runnable) { Inferno::Repositories::Tests.new.find('crd_service_request_context_validation') } + let(:session_data_repo) { Inferno::Repositories::SessionData.new } + let(:results_repo) { Inferno::Repositories::Results.new } + let(:test_session) { repo_create(:test_session, test_suite_id: 'crd_server') } + let(:context) do + json = File.read(File.join(__dir__, '..', 'fixtures', 'appointment_book_hook_request.json')) + JSON.parse(json)['context'] + end + let(:encounter_start_context) do + { 'userId' => 'PractitionerRole/A2340113', 'patientId' => '1288992', 'encounterId' => '456' } + end + let(:apppointment_book_context_required_fields) { ['userId', 'patientId', 'appointments'] } + let(:encounter_start_context_required_fields) { ['userId', 'patientId', 'encounterId'] } + + def run(runnable, inputs = {}) + test_run_params = { test_session_id: test_session.id }.merge(runnable.reference_hash) + test_run = Inferno::Repositories::TestRuns.new.create(test_run_params) + inputs.each do |name, value| + session_data_repo.save( + test_session_id: test_session.id, + name:, + value:, + type: runnable.config.input_type(name) + ) + end + Inferno::TestRunner.new(test_session:, test_run:).run(runnable) + end + + def entity_result_message + results_repo.current_results_for_test_session_and_runnables(test_session.id, [runnable]) + .first + .messages + .first + end + + context 'when appointment-book hook' do + let(:context) do + json = File.read(File.join(__dir__, '..', 'fixtures', 'appointment_book_hook_request.json')) + JSON.parse(json)['context'] + end + let(:apppointment_book_context_required_fields) { ['userId', 'patientId', 'appointments'] } + + before do + allow_any_instance_of(runnable).to receive(:hook_name).and_return('appointment-book') + allow_any_instance_of(runnable).to receive(:resource_is_valid?).and_return(true) + end + + it 'fails if a required field is missing' do + apppointment_book_context_required_fields.each do |field| + context_dup = context.deep_dup + context_dup.delete(field) + + result = run(runnable, contexts: [context_dup].to_json) + expect(result.result).to eq('fail') + expect(entity_result_message.message).to match(/does not contain required field `#{field}`/) + end + end + + it 'fails if required field is a wrong type' do + apppointment_book_context_required_fields.each do |field| + context_dup = context.deep_dup + context_dup[field] = 123 + + result = run(runnable, contexts: [context_dup].to_json) + expect(result.result).to eq('fail') + expect(entity_result_message.message).to match(/field `#{field}` is not of type/) + end + end + + it 'fails if userId is not correctly formatted `resource_type/resource_id`' do + ['/', 'Appointment/', '/123'].each do |string| + context_dup = context.deep_dup + context_dup['userId'] = string + + result = run(runnable, contexts: [context_dup].to_json) + expect(result.result).to eq('fail') + expect(entity_result_message.message).to match(/Invalid `userId` format/) + end + end + + it 'fails if user type is not Practitioner, PractitionerRole, Patient, or RelatedPerson' do + context_dup = context.deep_dup + context_dup['userId'] = 'Condition/123' + + result = run(runnable, contexts: [context_dup].to_json) + expect(result.result).to eq('fail') + expect(entity_result_message.message).to match(/Unsupported resource type/) + end + + it 'fails if patientId is a reference instead of a plain ID' do + context_dup = context.deep_dup + context_dup['patientId'] = 'Patient/123' + + result = run(runnable, contexts: [context_dup].to_json) + expect(result.result).to eq('fail') + expect(entity_result_message.message).to match(/should be a plain ID, not a reference/) + end + + it 'fails if appointments field is not a FHIR resource' do + context_dup = context.deep_dup + context_dup['appointments'] = { a: 1 } + + result = run(runnable, contexts: [context_dup].to_json) + expect(result.result).to eq('fail') + expect(entity_result_message.message).to match(/is not a FHIR resource/) + end + + it 'fails if appointments field is not a FHIR Bundle' do + context_dup = context.deep_dup + context_dup['appointments'] = { resourceType: 'Patient', id: 'bundle-example' } + + result = run(runnable, contexts: [context_dup].to_json) + expect(result.result).to eq('fail') + expect(entity_result_message.message).to match(/Expected `Bundle`/) + end + + it 'fails if bundle does not have at least one Appointment resource' do + context_dup = context.deep_dup + context_dup['appointments']['entry'].each do |entry| + entry['resource']['resourceType'] = 'Patient' + end + + result = run(runnable, contexts: [context_dup].to_json) + expect(result.result).to eq('fail') + expect(entity_result_message.message).to match(/bundle must contain at least one of the expected resource types/) + end + + it 'fails if any of the Appointment resources in the bundle does not have a status of proposed' do + context_dup = context.deep_dup + context_dup['appointments']['entry'].first['resource']['status'] = 'pending' + + result = run(runnable, contexts: [context_dup].to_json) + expect(result.result).to eq('fail') + expect(entity_result_message.message).to match(/must have a `proposed` status/) + end + + it 'passes if context contains optional `encounterId` field' do + context_dup = context.deep_dup + context_dup['encounterId'] = 'example' + result = run(runnable, contexts: [context_dup].to_json) + expect(result.result).to eq('pass') + end + end + + context 'when encounter-start or encounter-discharge hook' do + let(:encounter_context) do + { 'userId' => 'PractitionerRole/A2340113', 'patientId' => '1288992', 'encounterId' => '456' } + end + + let(:encounter_context_required_fields) { ['userId', 'patientId', 'encounterId'] } + + before do + hook = ['encounter-start', 'encounter-discharge'].sample + allow_any_instance_of(runnable).to receive(:hook_name).and_return(hook) + end + + it 'passes if all encounter-start contexts provided are valid' do + result = run(runnable, contexts: [encounter_context].to_json) + expect(result.result).to eq('pass') + end + + it 'fails if a required field is missing' do + encounter_context_required_fields.each do |field| + context_dup = encounter_context.deep_dup + context_dup.delete(field) + + result = run(runnable, contexts: [context_dup].to_json) + expect(result.result).to eq('fail') + expect(entity_result_message.message).to match(/does not contain required field `#{field}`/) + end + end + + it 'fails if required field is a wrong type' do + encounter_context_required_fields.each do |field| + context_dup = encounter_context.deep_dup + context_dup[field] = 123 + + result = run(runnable, contexts: [context_dup].to_json) + expect(result.result).to eq('fail') + expect(entity_result_message.message).to match(/field `#{field}` is not of type/) + end + end + + it 'fails if userId is not correctly formatted `resource_type/resource_id`' do + ['/', 'Practitioner/', '/123'].each do |string| + context_dup = encounter_context.deep_dup + context_dup['userId'] = string + + result = run(runnable, contexts: [context_dup].to_json) + expect(result.result).to eq('fail') + expect(entity_result_message.message).to match(/Invalid `userId` format/) + end + end + + it 'fails if user type is not Practitioner or PractitionerRole' do + context_dup = encounter_context.deep_dup + context_dup['userId'] = 'Patient/123' + + result = run(runnable, contexts: [context_dup].to_json) + expect(result.result).to eq('fail') + expect(entity_result_message.message).to match(/Unsupported resource type/) + end + + it 'fails if patientId or encounterId is a reference instead of a plain ID' do + ['patientId', 'encounterId'].each do |field| + context_dup = encounter_context.deep_dup + context_dup[field].prepend('/') + result = run(runnable, contexts: [context_dup].to_json) + expect(result.result).to eq('fail') + expect(entity_result_message.message).to match(/should be a plain ID, not a reference/) + end + end + end + + context 'when order-select hook' do + let(:order_select_context) do + json = File.read(File.join(__dir__, '..', 'fixtures', 'order_select_context.json')) + JSON.parse(json) + end + let(:order_select_context_required_fields) { ['userId', 'patientId', 'selections', 'draftOrders'] } + + before do + allow_any_instance_of(runnable).to receive(:hook_name).and_return('order-select') + allow_any_instance_of(runnable).to receive(:resource_is_valid?).and_return(true) + end + + it 'passes if all encounter-start contexts provided are valid' do + result = run(runnable, contexts: [order_select_context].to_json) + expect(result.result).to eq('pass') + end + + it 'fails if a required field is missing' do + order_select_context_required_fields.each do |field| + context_dup = order_select_context.deep_dup + context_dup.delete(field) + + result = run(runnable, contexts: [context_dup].to_json) + expect(result.result).to eq('fail') + expect(entity_result_message.message).to match(/does not contain required field `#{field}`/) + end + end + + it 'fails if required field is a wrong type' do + order_select_context_required_fields.each do |field| + context_dup = order_select_context.deep_dup + context_dup[field] = 123 + + result = run(runnable, contexts: [context_dup].to_json) + expect(result.result).to eq('fail') + expect(entity_result_message.message).to match(/field `#{field}` is not of type/) + end + end + + it 'fails if userId is not correctly formatted `resource_type/resource_id`' do + ['/', 'Practitioner/', '/123'].each do |string| + context_dup = order_select_context.deep_dup + context_dup['userId'] = string + + result = run(runnable, contexts: [context_dup].to_json) + expect(result.result).to eq('fail') + expect(entity_result_message.message).to match(/Invalid `userId` format/) + end + end + + it 'fails if user type is not Practitioner or PractitionerRole' do + context_dup = order_select_context.deep_dup + context_dup['userId'] = 'Patient/123' + + result = run(runnable, contexts: [context_dup].to_json) + expect(result.result).to eq('fail') + expect(entity_result_message.message).to match(/Unsupported resource type/) + end + + it 'fails if patientId is a reference instead of a plain ID' do + context_dup = order_select_context.deep_dup + context_dup['patientId'] = 'Patient/123' + + result = run(runnable, contexts: [context_dup].to_json) + expect(result.result).to eq('fail') + expect(entity_result_message.message).to match(/should be a plain ID, not a reference/) + end + + it 'fails if draftOrders field is not a FHIR resource' do + context_dup = order_select_context.deep_dup + context_dup['draftOrders'] = { a: 1 } + + result = run(runnable, contexts: [context_dup].to_json) + expect(result.result).to eq('fail') + expect(entity_result_message.message).to match(/is not a FHIR resource/) + end + + it 'fails if draftOrders field is not a FHIR Bundle' do + context_dup = order_select_context.deep_dup + context_dup['draftOrders'] = { resourceType: 'Patient', id: 'bundle-example' } + + result = run(runnable, contexts: [context_dup].to_json) + expect(result.result).to eq('fail') + expect(entity_result_message.message).to match(/Expected `Bundle`/) + end + + it 'fails if the bundle does not contain at least one expected resource type' do + context_dup = order_select_context.deep_dup + context_dup['draftOrders']['entry'].each do |entry| + entry['resource']['resourceType'] = 'Patient' + end + + result = run(runnable, contexts: [context_dup].to_json) + expect(result.result).to eq('fail') + expect(entity_result_message.message).to match(/bundle must contain at least one of the expected resource types/) + end + + it 'fails if any item in selections field has an unsupported resource type' do + context_dup = order_select_context.deep_dup + context_dup['selections'] << 'Task/test' + + result = run(runnable, contexts: [context_dup].to_json) + expect(result.result).to eq('fail') + expect(entity_result_message.message).to match(/Unsupported resource type/) + end + + it 'fails if any item in selections field is not refenced in the drafOrders bundle' do + context_dup = order_select_context.deep_dup + context_dup['selections'] << 'MedicationRequest/test' + + result = run(runnable, contexts: [context_dup].to_json) + expect(result.result).to eq('fail') + expect(entity_result_message.message).to match(/must reference FHIR resources in `draftOrders`/) + end + end + + context 'when order-dispatch' do + let(:order_dispatch_context) do + { 'patientId' => '1288992', 'order' => 'ServiceRequest/proc002', 'performer' => 'Organization/some-performer' } + end + let(:order_dispatch_context_required_fields) { ['patientId', 'order', 'performer'] } + + before do + allow_any_instance_of(runnable).to receive(:hook_name).and_return('order-dispatch') + end + + it 'passes if all order-dispatch contexts provided are valid' do + result = run(runnable, contexts: [order_dispatch_context].to_json) + expect(result.result).to eq('pass') + end + + it 'fails if a required field is missing' do + order_dispatch_context_required_fields.each do |field| + context_dup = order_dispatch_context.deep_dup + context_dup.delete(field) + + result = run(runnable, contexts: [context_dup].to_json) + expect(result.result).to eq('fail') + expect(entity_result_message.message).to match(/does not contain required field `#{field}`/) + end + end + + it 'fails if required field is a wrong type' do + order_dispatch_context_required_fields.each do |field| + context_dup = order_dispatch_context.deep_dup + context_dup[field] = 123 + + result = run(runnable, contexts: [context_dup].to_json) + expect(result.result).to eq('fail') + expect(entity_result_message.message).to match(/field `#{field}` is not of type/) + end + end + + it 'fails if a required fied has a correct type but is empty' do + context_dup = order_dispatch_context.deep_dup + context_dup['patientId'] = '' + + result = run(runnable, contexts: [context_dup].to_json) + expect(result.result).to eq('fail') + expect(entity_result_message.message).to match(/`patientId` should not be an empty String/) + end + + it 'fails if performer is not correctly formatted `resource_type/resource_id`' do + ['/', 'Practitioner/', '/123'].each do |string| + context_dup = order_dispatch_context.deep_dup + context_dup['performer'] = string + + result = run(runnable, contexts: [context_dup].to_json) + expect(result.result).to eq('fail') + expect(entity_result_message.message).to match(/Invalid `performer` format/) + end + end + + it 'fails if order is not correctly formatted `resource_type/resource_id`' do + ['/', 'ServiceRequest/', '/123'].each do |string| + context_dup = order_dispatch_context.deep_dup + context_dup['order'] = string + + result = run(runnable, contexts: [context_dup].to_json) + expect(result.result).to eq('fail') + expect(entity_result_message.message).to match(/Invalid `order` format/) + end + end + + it 'fails if order is not DeviceRequest, ServiceRequest, NutritionOrder, MedicatioonRequest or VisioPrescription' do + context_dup = order_dispatch_context.deep_dup + context_dup['order'] = 'Patient/123' + result = run(runnable, contexts: [context_dup].to_json) + expect(result.result).to eq('fail') + expect(entity_result_message.message).to match(/Unsupported resource type/) + end + + it 'fails if patientId is a reference instead of a plain ID' do + context_dup = order_dispatch_context.deep_dup + context_dup['patientId'] = 'Patient/123' + + result = run(runnable, contexts: [context_dup].to_json) + expect(result.result).to eq('fail') + expect(entity_result_message.message).to match(/should be a plain ID, not a reference/) + end + + it 'fails if context `task` is not a task resource' do + context_dup = order_dispatch_context.deep_dup + context_dup['task'] = { resourceType: 'Patient' } + result = run(runnable, contexts: [context_dup].to_json) + expect(result.result).to eq('fail') + expect(entity_result_message.message).to match(/Field `task` must be a `Task`. Got `Patient`/) + end + end + + it 'skips if contexts is not provided' do + result = run(runnable) + expect(result.result).to eq('skip') + expect(result.result_message).to match(/'contexts' is nil, skipping test/) + end +end diff --git a/spec/davinci_crd_test_kit/service_response_validation_test_spec.rb b/spec/davinci_crd_test_kit/service_response_validation_test_spec.rb new file mode 100644 index 0000000..f2f3f1a --- /dev/null +++ b/spec/davinci_crd_test_kit/service_response_validation_test_spec.rb @@ -0,0 +1,338 @@ +RSpec.describe DaVinciCRDTestKit::ServiceResponseValidationTest do + let(:runnable) { Inferno::Repositories::Tests.new.find('crd_service_response_validation') } + let(:session_data_repo) { Inferno::Repositories::SessionData.new } + let(:results_repo) { Inferno::Repositories::Results.new } + let(:test_session) { repo_create(:test_session, test_suite_id: 'crd_server') } + let(:discovery_url) { 'http://example.com/cds-services' } + let(:service_id) { 'service_id' } + let(:valid_response_body_json) do + File.read(File.join(__dir__, '..', 'fixtures', 'crd_authorization_hook_response.json')) + end + let(:card_required_fields) { ['summary', 'indicator', 'source'] } + let(:body) { JSON.parse(valid_response_body_json) } + + def create_service_request(body: nil, status: 200, headers: nil) + repo_create( + :request, + direction: 'outgoing', + url: "#{discovery_url}/#{service_id}", + test_session_id: test_session.id, + response_body: body.is_a?(Hash) ? body.to_json : body, + status:, + headers: + ) + end + + def mock_server(body: nil, status: 200, headers: nil, hook: 'other') + allow_any_instance_of(runnable).to receive(:hook_name).and_return(hook) + request = create_service_request(body:, status:, headers:) + allow_any_instance_of(runnable).to receive(:requests).and_return([request]) + end + + def entity_result_message + results_repo.current_results_for_test_session_and_runnables(test_session.id, [runnable]) + .first + .messages + .first + end + + def run(runnable, inputs = {}) + test_run_params = { test_session_id: test_session.id }.merge(runnable.reference_hash) + test_run = Inferno::Repositories::TestRuns.new.create(test_run_params) + inputs.each do |name, value| + session_data_repo.save( + test_session_id: test_session.id, + name:, + value:, + type: runnable.config.input_type(name) + ) + end + Inferno::TestRunner.new(test_session:, test_run:).run(runnable) + end + + context 'when appointment-book or order-sign hook' do + let(:hook) { ['appointment-book', 'order-sign'].sample } + + it 'passes if response body contains valid cards and system actions' do + mock_server(body: valid_response_body_json, hook:) + result = run(runnable) + expect(result.result).to eq('pass') + end + + it 'fails if system actions is missing from the response' do + body.delete('systemActions') + mock_server(body: body.to_json, hook:) + + result = run(runnable) + expect(result.result).to eq('fail') + expect(entity_result_message.message).to match(/did not have `systemActions` field/) + end + + it 'fails if system actions is not an array' do + body['systemActions'] = {} + mock_server(body: body.to_json, hook:) + + result = run(runnable) + expect(result.result).to eq('fail') + expect(entity_result_message.message).to match(/is not an array/) + end + + it 'persists outputs' do + mock_server(body: valid_response_body_json, hook:) + + result = run(runnable) + expect(result.result).to eq('pass') + + persisted_cards = session_data_repo.load(test_session_id: test_session.id, name: :valid_cards) + persisted_actions = session_data_repo.load(test_session_id: test_session.id, name: :valid_system_actions) + expect(persisted_cards).to eq(body['cards'].to_json) + expect(persisted_actions).to eq(body['systemActions'].to_json) + end + end + + context 'when any hook' do + it 'passes if response body contains valid cards' do + mock_server(body: valid_response_body_json) + + result = run(runnable) + expect(result.result).to eq('pass') + end + + it 'skips if no successful requests' do + mock_server(status: 400) + + result = run(runnable) + expect(result.result).to eq('skip') + expect(result.result_message).to match(/All service requests were unsuccessful/) + end + + it 'passes with warning if response body `cards` is an empty array' do + mock_server(body: { 'cards' => [], 'systemActions' => [] }) + + result = run(runnable) + expect(result.result).to eq('pass') + expect(entity_result_message.message).to match(/no decision support/) + expect(entity_result_message.type).to eq('warning') + end + + it 'fails if response body is invalid json' do + mock_server(body: 'body') + + result = run(runnable) + expect(result.result).to eq('fail') + expect(entity_result_message.message).to match(/Invalid JSON/) + end + + it 'fails if cards is missing from a response' do + mock_server(body: {}) + + result = run(runnable) + expect(result.result).to eq('fail') + expect(entity_result_message.message).to match(/did not have the `cards` field/) + end + + it 'fails if cards is not an array in at least one of the responses' do + mock_server(body: { 'cards' => {} }) + + result = run(runnable) + expect(result.result).to eq('fail') + expect(entity_result_message.message).to match(/is not an array/) + end + + it 'fails if required field is missing from a card' do + card_required_fields.each do |field| + body_dup = body.deep_dup + body_dup['cards'].first.delete(field) + mock_server(body: body_dup) + + result = run(runnable) + expect(result.result).to eq('fail') + expect(entity_result_message.message).to match(/Card does not contain required field `#{field}`/) + end + end + + it 'fails if card required field is the wrong type' do + card_required_fields.each do |field| + body_dup = body.deep_dup + body_dup['cards'].first.merge!(field => 123) + mock_server(body: body_dup) + + result = run(runnable) + expect(result.result).to eq('fail') + expect(entity_result_message.message).to match(/Card field `#{field}` is not of type/) + end + end + + it 'fails if a card\'s summary if more than 140 characters' do + body_dup = body.deep_dup + body_dup['cards'].each do |card| + card['summary'] = SecureRandom.alphanumeric(150) + mock_server(body: body_dup) + + result = run(runnable) + expect(result.result).to eq('fail') + expect(entity_result_message.message).to match(/`summary` is over the 140-character limit/) + end + end + + it 'fails if a card\'s indicator value is not an allowed value' do + body_dup = body.deep_dup + body_dup['cards'].each do |card| + card['indicator'] = 'random' + mock_server(body: body_dup) + + result = run(runnable) + expect(result.result).to eq('fail') + expect(entity_result_message.message).to match(/Allowed values are `info`, `warning`, `critical`/) + end + end + + it 'fails if a required field is missing from a card source' do + ['label', 'topic'].each do |field| + body_dup = body.deep_dup + body_dup['cards'].first['source'].delete(field) + mock_server(body: body_dup) + + result = run(runnable) + expect(result.result).to eq('fail') + expect(entity_result_message.message).to match(/Source does not contain required field `#{field}`/) + end + end + + it 'fails if a card source required field is a wrong type' do + ['label', 'topic'].each do |field| + body_dup = body.deep_dup + body_dup['cards'].first['source'].merge!(field => 123) + mock_server(body: body_dup) + + result = run(runnable) + expect(result.result).to eq('fail') + expect(entity_result_message.message).to match(/Source field `#{field}` is not of type/) + end + end + + it 'fails if a required field is missing from a card source topic' do + ['code', 'system'].each do |field| + body_dup = body.deep_dup + body_dup['cards'].first['source']['topic'].delete(field) + mock_server(body: body_dup) + + result = run(runnable) + expect(result.result).to eq('fail') + expect(entity_result_message.message).to match(/Source topic does not contain required field `#{field}`/) + end + end + + it 'fails if a card source topic required field is a wrong type' do + ['code', 'system'].each do |field| + body_dup = body.deep_dup + body_dup['cards'].first['source']['topic'].merge!(field => 123) + mock_server(body: body_dup) + + result = run(runnable) + expect(result.result).to eq('fail') + expect(entity_result_message.message).to match(/Source topic field `#{field}` is not of type/) + end + end + + it 'fails if a required field is missing from systemAction' do + body_dup = body.deep_dup + body_dup['systemActions'] = [ + { 'type' => 'delete', 'resourceId' => ['MedicationRequest/smart-MedicationRequest-103'] } + ] + mock_server(body: body_dup) + result = run(runnable) + expect(result.result).to eq('fail') + expect(entity_result_message.message).to match(/does not contain required field `description`/) + end + + it 'fails if systemAction required field is a wrong type' do + body_dup = body.deep_dup + body_dup['systemActions'] = [ + { 'type' => 'delete', 'resourceId' => ['MedicationRequest/smart-MedicationRequest-103'], 'description' => 123 } + ] + mock_server(body: body_dup) + result = run(runnable) + expect(result.result).to eq('fail') + expect(entity_result_message.message).to match(/field `description` is not of type/) + end + + it 'fails systemAction.type is not an allowed value' do + body_dup = body.deep_dup + body_dup['systemActions'] = [ + { 'type' => 'abc', 'resourceId' => ['MedicationRequest/smart-MedicationRequest-103'], 'description' => 'ok' } + ] + mock_server(body: body_dup) + result = run(runnable) + expect(result.result).to eq('fail') + expect(entity_result_message.message).to match(/is not allowed/) + end + + it 'fails if a create action does not have a resource field' do + body_dup = body.deep_dup + body_dup['systemActions'] = [ + { 'type' => 'create', 'description' => 'ok' } + ] + mock_server(body: body_dup) + result = run(runnable) + expect(result.result).to eq('fail') + expect(entity_result_message.message).to match(/Action.resource` must be present/) + end + + it 'fails if a create action resource is not a FHIR resource' do + body_dup = body.deep_dup + body_dup['systemActions'] = [ + { 'type' => 'create', 'description' => 'ok', 'resource' => '123' } + ] + mock_server(body: body_dup) + result = run(runnable) + expect(result.result).to eq('fail') + expect(entity_result_message.message).to match(/`Action.resource` must be a FHIR resource/) + end + + it 'fails if a delete action does not have a resourceId field' do + body_dup = body.deep_dup + body_dup['systemActions'] = [ + { 'type' => 'delete', 'description' => '123' } + ] + mock_server(body: body_dup) + result = run(runnable) + expect(result.result).to eq('fail') + expect(entity_result_message.message).to match(/does not contain required field `resourceId`/) + end + + it 'fails if a delete action resourceId is not an array' do + body_dup = body.deep_dup + body_dup['systemActions'] = [ + { 'type' => 'delete', 'description' => '123', 'resourceId' => '123' } + ] + mock_server(body: body_dup) + result = run(runnable) + expect(result.result).to eq('fail') + expect(entity_result_message.message).to match(/is not of type `Array`/) + end + + it 'fails if a delete action resourceId item is not a relative reference' do + body_dup = body.deep_dup + body_dup['systemActions'] = [ + { 'type' => 'delete', 'description' => '123', 'resourceId' => ['123'] } + ] + mock_server(body: body_dup) + result = run(runnable) + expect(result.result).to eq('fail') + expect(entity_result_message.message).to match(/Invalid `Action.resourceId item` format/) + end + + it 'persists outputs' do + mock_server(body: valid_response_body_json) + + result = run(runnable) + expect(result.result).to eq('pass') + + persisted_cards = session_data_repo.load(test_session_id: test_session.id, name: :valid_cards) + persisted_actions = session_data_repo.load(test_session_id: test_session.id, name: :valid_system_actions) + expect(persisted_cards).to eq(body['cards'].to_json) + expect(persisted_actions).to eq(body['systemActions'].to_json) + end + end +end diff --git a/spec/davinci_crd_test_kit/token_header_test_spec.rb b/spec/davinci_crd_test_kit/token_header_test_spec.rb new file mode 100644 index 0000000..531409d --- /dev/null +++ b/spec/davinci_crd_test_kit/token_header_test_spec.rb @@ -0,0 +1,80 @@ +require_relative '../../lib/davinci_crd_test_kit/client_tests/token_header_test' + +RSpec.describe DaVinciCRDTestKit::TokenHeaderTest do + let(:test) { Inferno::Repositories::Tests.new.find('crd_token_header') } + let(:session_data_repo) { Inferno::Repositories::SessionData.new } + let(:test_session) { repo_create(:test_session, test_suite_id: 'crd_client') } + + let(:example_client_url) { 'https://cds.example.org' } + let(:base_url) { "#{Inferno::Application['base_url']}/custom/crd_client" } + let(:jwks_hash_keys) { JSON.parse(DaVinciCRDTestKit::JWKS.jwks_json)['keys'] } + let(:jwk) { jwks_hash_keys.find { |key| key['alg'] == 'RS384' } } + + let(:token_header) do + { + alg: 'RS384', + kid: jwk['kid'], + typ: 'JWT', + jku: "#{example_client_url}/jwks.json" + } + end + + def run(runnable, inputs = {}) + test_run_params = { test_session_id: test_session.id }.merge(runnable.reference_hash) + test_run = Inferno::Repositories::TestRuns.new.create(test_run_params) + inputs.each do |name, value| + session_data_repo.save( + test_session_id: test_session.id, + name:, + value:, + type: runnable.config.input_type(name) + ) + end + Inferno::TestRunner.new(test_session:, test_run:).run(runnable) + end + + it 'passes if it receives a valid JWT Authorization header' do + result = run(test, auth_token_header_json: token_header.to_json, crd_jwks_keys_json: jwks_hash_keys.to_json) + expect(result.result).to eq('pass') + end + + it 'fails if it receives a JWT header without the `alg` field' do + invalid_token_header = token_header.except(:alg) + + result = run(test, auth_token_header_json: invalid_token_header.to_json, crd_jwks_keys_json: jwks_hash_keys.to_json) + expect(result.result).to eq('fail') + expect(result.result_message).to eq('Token header must have the `alg` field') + end + + it 'fails if it receives a JWT header without the `typ` field' do + invalid_token_header = token_header.except(:typ) + + result = run(test, auth_token_header_json: invalid_token_header.to_json, crd_jwks_keys_json: jwks_hash_keys.to_json) + expect(result.result).to eq('fail') + expect(result.result_message).to eq('Token header must have the `typ` field') + end + + it 'fails if it receives a JWT header with the `typ` field not set to JWT' do + token_header[:typ] = 'Bearer' + + result = run(test, auth_token_header_json: token_header.to_json, crd_jwks_keys_json: jwks_hash_keys.to_json) + expect(result.result).to eq('fail') + expect(result.result_message).to eq("Token header `typ` field must be set to 'JWT', instead was Bearer") + end + + it 'fails if it receives a JWT header without the `kid` field' do + invalid_token_header = token_header.except(:kid) + + result = run(test, auth_token_header_json: invalid_token_header.to_json, crd_jwks_keys_json: jwks_hash_keys.to_json) + expect(result.result).to eq('fail') + expect(result.result_message).to eq('Token header must have the `kid` field') + end + + it 'fails if it receives a JWT header that does not contain a kid found in the jwks' do + token_header[:kid] = '12345' + + result = run(test, auth_token_header_json: token_header.to_json, crd_jwks_keys_json: jwks_hash_keys.to_json) + expect(result.result).to eq('fail') + expect(result.result_message).to eq('JWKS did not contain a public key with an id of `12345`') + end +end diff --git a/spec/davinci_crd_test_kit/token_payload_test_spec.rb b/spec/davinci_crd_test_kit/token_payload_test_spec.rb new file mode 100644 index 0000000..b0c1e6f --- /dev/null +++ b/spec/davinci_crd_test_kit/token_payload_test_spec.rb @@ -0,0 +1,148 @@ +require_relative '../../lib/davinci_crd_test_kit/client_tests/token_payload_test' +require_relative '../../lib/davinci_crd_test_kit/jwt_helper' + +RSpec.describe DaVinciCRDTestKit::TokenPayloadTest do + let(:suite) { Inferno::Repositories::TestSuites.new.find('crd_client') } + let(:session_data_repo) { Inferno::Repositories::SessionData.new } + let(:test_session) { repo_create(:test_session, test_suite_id: 'crd_client') } + let(:jwt_helper) { Class.new(DaVinciCRDTestKit::JwtHelper) } + + let(:example_client_url) { 'https://cds.example.org' } + let(:base_url) { "#{Inferno::Application['base_url']}/custom/crd_client" } + let(:appointment_book_url) { "#{base_url}/cds-services/appointment-book-service" } + + let(:jwks_hash) { JSON.parse(DaVinciCRDTestKit::JWKS.jwks_json) } + let(:jwk) { jwks_hash['keys'].find { |key| key['alg'] == 'RS384' } } + + let(:rsa_key) { OpenSSL::PKey::RSA.new(2048) } + let(:rsa_jwk) { JWT::JWK.new(rsa_key) } + let(:rsa_jwk_hash) { JSON.parse(rsa_jwk.to_json)['parameters'] } + + let(:token_header) do + { + alg: 'RS384', + kid: rsa_jwk['kid'], + typ: 'JWT', + jku: "#{example_client_url}/jwks.json" + } + end + + let(:token_payload) do + { + aud: appointment_book_url, + iss: example_client_url, + iat: Time.now.to_i, + exp: 5.minutes.from_now.to_i, + jti: SecureRandom.hex(32) + } + end + + def run(runnable, inputs = {}) + test_run_params = { test_session_id: test_session.id }.merge(runnable.reference_hash) + test_run = Inferno::Repositories::TestRuns.new.create(test_run_params) + inputs.each do |name, value| + session_data_repo.save( + test_session_id: test_session.id, + name:, + value:, + type: runnable.config.input_type(name) + ) + end + Inferno::TestRunner.new(test_session:, test_run:).run(runnable) + end + + describe 'CRD Appointment Book Token Payload' do + let(:test) do + Class.new(DaVinciCRDTestKit::TokenPayloadTest) do + input :auth_token, + :auth_token_jwk_json, + :iss + config( + options: { hook_path: '/cds-services/appointment-book-service' } + ) + end + end + + it 'passes if it receives a valid JWT payload' do + allow(test).to receive(:suite).and_return(suite) + + token = jwt_helper.build( + aud: appointment_book_url, + iss: example_client_url, + jku: "#{example_client_url}/jwks.json", + encryption_method: 'RS384' + ) + + result = run(test, auth_token: token, auth_token_jwk_json: jwk.to_json, iss: example_client_url) + expect(result.result).to eq('pass') + end + + it 'fails if it receives a JWT payload with an invalid `iss` field' do + allow(test).to receive(:suite).and_return(suite) + + token = jwt_helper.build( + aud: appointment_book_url, + iss: 'incorrect_iss.com', + jku: "#{example_client_url}/jwks.json", + encryption_method: 'RS384' + ) + + result = run(test, auth_token: token, auth_token_jwk_json: jwk.to_json, iss: example_client_url) + expect(result.result).to eq('fail') + expect(result.result_message).to eq( + 'Token validation error: Invalid issuer. Expected ["https://cds.example.org"], received incorrect_iss.com' + ) + end + + it 'fails if it receives a JWT payload with an invalid `aud` field' do + allow(test).to receive(:suite).and_return(suite) + + token = jwt_helper.build( + aud: 'incorrect_aud.com', + iss: example_client_url, + jku: "#{example_client_url}/jwks.json", + encryption_method: 'RS384' + ) + + result = run(test, auth_token: token, auth_token_jwk_json: jwk.to_json, iss: example_client_url) + expect(result.result).to eq('fail') + expect(result.result_message).to match( + 'Token validation error: Invalid audience. Expected http://localhost:4567/custom/crd_client/cds-services/appointment-book-service' + ) + end + + it 'fails if it receives a JWT Authorization header with invalid signature' do + allow(test).to receive(:suite).and_return(suite) + + token = jwt_helper.build( + aud: appointment_book_url, + iss: example_client_url, + jku: "#{example_client_url}/jwks.json", + encryption_method: 'RS384' + ) + + payload, header = jwt_helper.decode_jwt(token, jwks_hash) + token_invalid_key = JWT.encode payload, OpenSSL::PKey::RSA.new(2048), 'RS384', header + + result = run(test, auth_token: token_invalid_key, auth_token_jwk_json: jwk.to_json, iss: example_client_url) + expect(result.result).to eq('fail') + expect(result.result_message).to eq('Token validation error: Signature verification failed') + end + + it 'fails if it receives a JWT Authorization header with missing claims' do + allow(test).to receive(:suite).and_return(suite) + + rsa_jwk_hash['alg'] = 'RS384' + invalid_payload = token_payload.except(:exp).except(:exp) + + token_invalid_key = JWT.encode invalid_payload, rsa_key, 'RS384', token_header + + result = run(test, + auth_token: token_invalid_key, + auth_token_jwk_json: rsa_jwk_hash.to_json, + iss: example_client_url) + expect(result.result).to eq('fail') + expect(result.result_message).to eq('JWT payload missing required claims: `exp`') + end + end +end diff --git a/spec/fixtures/appointment_book_hook_request.json b/spec/fixtures/appointment_book_hook_request.json new file mode 100644 index 0000000..1c8da4c --- /dev/null +++ b/spec/fixtures/appointment_book_hook_request.json @@ -0,0 +1,119 @@ +{ + "hookInstance": "d1577c69-dfbe-44ad-ba6d-3e05e953b2ea", + "fhirServer": "https://example/r4", + "hook": "appointment-book", + "fhirAuthorization": { + "access_token": "SAMPLE_TOKEN", + "token_type": "Bearer", + "expires_in": 300, + "scope": "user/Patient.read user/Observation.read", + "subject": "cds-service" + }, + "context": { + "userId": "Practitioner/example", + "patientId": "example", + "appointments": { + "resourceType": "Bundle", + "id": "bundle-example", + "type": "searchset", + "total": 2, + "entry": [ + { + "fullUrl": "https://example/r4/Appointment/apt1", + "resource": { + "resourceType": "Appointment", + "id": "apt1", + "status": "proposed", + "serviceType": [ + { + "coding": [ + { + "code": "183", + "display": "Sleep Medicine" + } + ] + } + ], + "appointmentType": { + "coding": [ + { + "system": "http://hl7.org/fhir/v2/0276", + "code": "FOLLOWUP", + "display": "A follow up visit from a previous appointment" + } + ] + }, + "reason": { + "coding": { + "system": "", + "code": "1023001", + "display": "Apnea" + } + }, + "description": "CPAP adjustments", + "start": "2019-08-10T09:00:00-06:00", + "end": "2019-08-10T09:10:00:00-06:00", + "created": "2019-08-01", + "participant": [ + { + "actor": { + "reference": "Patient/example", + "display": "Peter James Chalmers" + }, + "required": "required", + "status": "tentative" + }, + { + "actor": { + "reference": "Practitioner/example", + "display": "Dr Adam Careful" + }, + "required": "required", + "status": "accepted" + } + ] + } + }, + { + "fullUrl": "https://example.com/Appointment/apt2", + "resource": { + "resourceType": "Appointment", + "id": "apt2", + "status": "proposed", + "appointmentType": { + "coding": [ + { + "system": "http://hl7.org/fhir/v2/0276", + "code": "CHECKUP", + "display": "A routine check-up, such as an annual physical" + } + ] + }, + "description": "Regular physical", + "start": "2020-08-01T13:00:00-06:00", + "end": "2020-08-01T13:30:00:00-06:00", + "created": "2019-08-01", + "participant": [ + { + "actor": { + "reference": "Patient/example", + "display": "Peter James Chalmers" + }, + "required": "required", + "status": "tentative" + }, + { + "actor": { + "reference": "Practitioner/example", + "display": "Dr Adam Careful" + }, + "required": "required", + "status": "accepted" + } + ] + } + } + ] + } + } +} \ No newline at end of file diff --git a/spec/fixtures/crd_authorization_hook_response.json b/spec/fixtures/crd_authorization_hook_response.json new file mode 100644 index 0000000..422f713 --- /dev/null +++ b/spec/fixtures/crd_authorization_hook_response.json @@ -0,0 +1,248 @@ +{ + "cards": [ + { + "summary": "Appointment Book Instructions Card", + "detail": "This is an Instructions card containing textual guidance to display to the user making the decisions.", + "indicator": "info", + "source": { + "label": "Inferno", + "url": "https://inferno.healthit.gov/", + "topic": { + "system": "http://hl7.org/fhir/us/davinci-crd/CodeSystem/temp", + "code": "appointment-book", + "display": "Appointment Book" + } + } + }, + { + "summary": "Appointment Book External Reference Card", + "detail": "This is an External Reference Card containing one or more links to external web pages, PDFs, or other resources that provide relevant coverage information.", + "indicator": "info", + "source": { + "label": "Inferno", + "url": "https://inferno.healthit.gov/", + "topic": { + "system": "http://hl7.org/fhir/us/davinci-crd/CodeSystem/temp", + "code": "appointment-book", + "display": "Appointment Book" + } + }, + "links": [ + { + "label": "CRD IG External Reference Card Info", + "url": "https://build.fhir.org/ig/HL7/davinci-crd/cards.html#external-reference", + "type": "absolute" + } + ] + } + ], + "systemActions": [ + { + "type": "update", + "description": "Added coverage information to appointment resource.", + "resource": { + "resourceType": "Appointment", + "id": "example", + "status": "proposed", + "extension": [ + { + "extension": [ + { + "url": "coverage", + "valueReference": { + "reference": "http://example.org/fhir/Coverage/example" + } + }, + { + "url": "covered", + "valueCode": "covered" + }, + { + "url": "pa-needed", + "valueCode": "satisfied" + }, + { + "url": "billingCode", + "valueCoding": { + "system": "http://www.ama-assn.org/go/cpt", + "code": "77065" + } + }, + { + "url": "billingCode", + "valueCoding": { + "system": "http://www.ama-assn.org/go/cpt", + "code": "77066" + } + }, + { + "url": "billingCode", + "valueCoding": { + "system": "http://www.ama-assn.org/go/cpt", + "code": "77067" + } + }, + { + "url": "reason", + "valueCodeableConcept": { + "text": "In-network required unless exigent circumstances" + } + }, + { + "extension": [ + { + "url": "code", + "valueCodeableConcept": { + "coding": [ + { + "system": "http://hl7.org/fhir/us/davinci-crd/CodeSystem/temp", + "code": "auth-out-network-only" + } + ] + } + }, + { + "url": "value", + "valueBoolean": true + }, + { + "url": "qualification", + "valueString": "Out-of-network prior auth does not apply if delivery occurs at a service site designated as 'remote'" + } + ], + "url": "detail" + }, + { + "url": "dependency", + "valueReference": { + "reference": "http://example.org/fhir/ServiceRequest/example2" + } + }, + { + "url": "date", + "valueDate": "2019-02-15" + }, + { + "url": "coverage-assertion-id", + "valueString": "12345ABC" + }, + { + "url": "satisfied-pa-id", + "valueString": "Q8U119" + }, + { + "url": "contact", + "valueContactPoint": { + "system": "url", + "value": "http://some-payer/xyz-sub-org/get-help-here.html" + } + } + ], + "url": "http://hl7.org/fhir/us/davinci-crd/StructureDefinition/ext-coverage-information" + } + ], + "serviceCategory": [ + { + "coding": [ + { + "system": "http://terminology.hl7.org/CodeSystem/service-category", + "code": "17", + "display": "General Practice" + } + ] + } + ], + "serviceType": [ + { + "coding": [ + { + "system": "http://terminology.hl7.org/CodeSystem/service-type", + "code": "124", + "display": "General Practice" + } + ] + } + ], + "specialty": [ + { + "coding": [ + { + "system": "http://snomed.info/sct", + "code": "394814009", + "display": "General practice (specialty)" + } + ] + } + ], + "appointmentType": { + "coding": [ + { + "system": "http://terminology.hl7.org/CodeSystem/v2-0276", + "code": "FOLLOWUP", + "display": "A follow up visit from a previous appointment" + } + ] + }, + "reasonReference": [ + { + "reference": "http://example.org/fhir/Condition/example", + "display": "Heart problem" + } + ], + "priority": 5, + "description": "Discussion on the results of your recent MRI", + "start": "2013-12-10T09:00:00Z", + "end": "2013-12-10T11:00:00Z", + "created": "2013-10-10", + "comment": "Further expand on the results of the MRI and determine the next actions that may be appropriate.", + "basedOn": [ + { + "reference": "ServiceRequest/example" + } + ], + "participant": [ + { + "actor": { + "reference": "Patient/example", + "display": "Amy Baxter" + }, + "required": "required", + "status": "accepted" + }, + { + "type": [ + { + "coding": [ + { + "system": "http://terminology.hl7.org/CodeSystem/v3-ParticipationType", + "code": "ATND" + } + ] + } + ], + "actor": { + "reference": "Practitioner/example", + "display": "Dr Adam Careful" + }, + "required": "required", + "status": "accepted" + }, + { + "actor": { + "reference": "Location/example", + "display": "South Wing, second floor" + }, + "required": "required", + "status": "accepted" + } + ], + "requestedPeriod": [ + { + "start": "2020-11-01", + "end": "2020-12-15" + } + ] + } + } + ] +} diff --git a/spec/fixtures/crd_coverage_example.json b/spec/fixtures/crd_coverage_example.json new file mode 100644 index 0000000..965c152 --- /dev/null +++ b/spec/fixtures/crd_coverage_example.json @@ -0,0 +1,60 @@ +{ + "resourceType" : "Coverage", + "id" : "coverage_example", + "text" : { + "status" : "generated", + "div" : "

Generated Narrative: Coverage

Resource Coverage "example"

identifier: Member Number:\u00a012345

status: active

type: extended healthcare (ActCode#EHCPOL)

policyHolder: http://example.org/FHIR/Organization/CBI35

subscriber: Patient/example " SHAW"

beneficiary: Patient/example " SHAW"

dependent: 0

relationship: Self (SubscriberPolicyholder Relationship Codes#self)

period: 2011-05-23 --> 2012-05-23

payor: http://example.org/fhir/Organization/example-payer: Payer XYZ

Classes

-TypeValueName
*Group (Coverage Class Codes#group)CB135Corporate Baker's Inc. Local #35
" + }, + "identifier" : [{ + "type" : { + "coding" : [{ + "system" : "http://terminology.hl7.org/CodeSystem/v2-0203", + "code" : "MB" + }] + }, + "system" : "http://example.com/fhir/NampingSystem/certificate", + "value" : "12345" + }], + "status" : "active", + "type" : { + "coding" : [{ + "system" : "http://terminology.hl7.org/CodeSystem/v3-ActCode", + "code" : "EHCPOL", + "display" : "extended healthcare" + }] + }, + "policyHolder" : { + "reference" : "http://example.org/FHIR/Organization/CBI35" + }, + "subscriber" : { + "reference" : "Patient/example" + }, + "beneficiary" : { + "reference" : "Patient/example" + }, + "dependent" : "0", + "relationship" : { + "coding" : [{ + "system" : "http://terminology.hl7.org/CodeSystem/subscriber-relationship", + "code" : "self" + }] + }, + "period" : { + "start" : "2011-05-23", + "end" : "2012-05-23" + }, + "payor" : [{ + "reference" : "http://example.org/fhir/Organization/example-payer", + "display" : "Payer XYZ" + }], + "class" : [{ + "type" : { + "coding" : [{ + "system" : "http://terminology.hl7.org/CodeSystem/coverage-class", + "code" : "group" + }] + }, + "value" : "CB135", + "name" : "Corporate Baker's Inc. Local #35" + }] +} \ No newline at end of file diff --git a/spec/fixtures/crd_encounter_example.json b/spec/fixtures/crd_encounter_example.json new file mode 100644 index 0000000..6f358b3 --- /dev/null +++ b/spec/fixtures/crd_encounter_example.json @@ -0,0 +1,93 @@ +{ + "resourceType" : "Encounter", + "id" : "example", + "text" : { + "status" : "generated", + "div" : "

Generated Narrative: Encounter

Resource Encounter "example"

identifier: id:\u00a0v1451\u00a0(use:\u00a0OFFICIAL)

status: in-progress

class: ambulatory (Details: http://terminology.hl7.org/CodeSystem/v3-ActCode code AMB = 'ambulatory', stated as 'ambulatory')

type: Patient-initiated encounter (SNOMED CT#270427003)

priority: Non-urgent cardiological admission (SNOMED CT#310361003)

subject: Patient/example " SHAW"

Participants

-Individual
*Practitioner/example " CAREFUL"

Lengths

-ValueUnitSystemCode
*140minUnified Code for Units of Measure (UCUM)min

reasonCode: Heart valve replacement (SNOMED CT#34068001)

Hospitalizations

-PreAdmissionIdentifierAdmitSourceDischargeDisposition
*id:\u00a093042\u00a0(use:\u00a0OFFICIAL)Referral by physician (SNOMED CT#305956004)Discharge to home (SNOMED CT#306689006)

serviceProvider: Organization/example: University Medical Center "University Medical Center"

" + }, + "identifier" : [{ + "use" : "official", + "system" : "http://www.amc.nl/zorgportal/identifiers/visits", + "value" : "v1451" + }], + "status" : "in-progress", + "class" : { + "system" : "http://terminology.hl7.org/CodeSystem/v3-ActCode", + "code" : "AMB", + "display" : "ambulatory" + }, + "type" : [{ + "coding" : [{ + "system" : "http://snomed.info/sct", + "code" : "270427003", + "display" : "Patient-initiated encounter" + }] + }], + "priority" : { + "coding" : [{ + "system" : "http://snomed.info/sct", + "code" : "310361003", + "display" : "Non-urgent cardiological admission" + }] + }, + "subject" : { + "reference" : "Patient/example" + }, + "participant" : [{ + "individual" : { + "reference" : "Practitioner/example" + } + }], + "length" : { + "value" : 140, + "unit" : "min", + "system" : "http://unitsofmeasure.org", + "code" : "min" + }, + "reasonCode" : [{ + "coding" : [{ + "system" : "http://snomed.info/sct", + "code" : "34068001", + "display" : "Heart valve replacement" + }] + }], + "hospitalization" : { + "preAdmissionIdentifier" : { + "use" : "official", + "system" : "http://www.amc.nl/zorgportal/identifiers/pre-admissions", + "value" : "93042" + }, + "admitSource" : { + "coding" : [{ + "system" : "http://snomed.info/sct", + "code" : "305956004", + "display" : "Referral by physician" + }] + }, + "dischargeDisposition" : { + "coding" : [{ + "system" : "http://snomed.info/sct", + "code" : "306689006", + "display" : "Discharge to home" + }] + } + }, + "location": [ + { + "location": { + "reference": "Location/example", + "display": "Location1" + } + }, + { + "location": { + "reference": "Location/example2", + "display": "Location2" + } + } +], + "serviceProvider" : { + "reference" : "Organization/example", + "display" : "University Medical Center" + } +} \ No newline at end of file diff --git a/spec/fixtures/crd_location_example.json b/spec/fixtures/crd_location_example.json new file mode 100644 index 0000000..aa39a28 --- /dev/null +++ b/spec/fixtures/crd_location_example.json @@ -0,0 +1,57 @@ +{ + "resourceType" : "Location", + "id" : "example", + "text" : { + "status" : "generated", + "div" : "

Generated Narrative: Location

Resource Location "example"

identifier: B1-S.F2

status: active

name: South Wing, second floor

alias: MC, SW, F2, University Medical Center, South Wing, second floor

description: Second floor of the Old South Wing, formerly in use by Psychiatry

mode: instance

telecom: ph: 2328(WORK), fax: 2329(WORK), second-wing-admissions@sampleorg.com, http://sampleorg.com/southwing

address: Galapagosweg 91, Building A San Francisco CA 94107 US (WORK)

physicalType: Wing (Location type#wi)

managingOrganization: Organization/example "University Medical Center"

endpoint: http://example.org/fhir/Endpoint/example

" + }, + "identifier" : [{ + "value" : "B1-S.F2" + }], + "status" : "active", + "name" : "South Wing, second floor", + "alias" : ["MC, SW, F2", + "University Medical Center, South Wing, second floor"], + "description" : "Second floor of the Old South Wing, formerly in use by Psychiatry", + "mode" : "instance", + "telecom" : [{ + "system" : "phone", + "value" : "2328", + "use" : "work" + }, + { + "system" : "fax", + "value" : "2329", + "use" : "work" + }, + { + "system" : "email", + "value" : "second-wing-admissions@sampleorg.com" + }, + { + "system" : "url", + "value" : "http://sampleorg.com/southwing", + "use" : "work" + }], + "address" : { + "use" : "work", + "line" : ["Galapagosweg 91, Building A"], + "city" : "San Francisco", + "state" : "CA", + "postalCode" : "94107", + "country" : "US" + }, + "physicalType" : { + "coding" : [{ + "system" : "http://terminology.hl7.org/CodeSystem/location-physical-type", + "code" : "wi", + "display" : "Wing" + }] + }, + "managingOrganization" : { + "reference" : "Organization/example" + }, + "endpoint" : [{ + "reference" : "http://example.org/fhir/Endpoint/example" + }] +} \ No newline at end of file diff --git a/spec/fixtures/crd_patient_example.json b/spec/fixtures/crd_patient_example.json new file mode 100644 index 0000000..07540b2 --- /dev/null +++ b/spec/fixtures/crd_patient_example.json @@ -0,0 +1,121 @@ +{ + "resourceType" : "Patient", + "id" : "example", + "text" : { + "status" : "generated", + "div" : "

Amy V. Shaw female, DoB: 1987-02-20 ( Medical Record Number:\u00a01032702\u00a0(use:\u00a0USUAL))


Active:true
Alt. Name:Amy V. Baxter
Contact Details:
  • ph: 555-555-5555(HOME)
  • amy.shaw@example.com
  • 49 Meadow St Mounds OK 74047 US
  • 183 Mountain View St Mounds OK 74048 US
US Core Ethnicity Extension:
US Core Birth Sex Extension:
  • F
US Core Race Extension:
" + }, + "extension" : [{ + "extension" : [{ + "url" : "ombCategory", + "valueCoding" : { + "system" : "urn:oid:2.16.840.1.113883.6.238", + "code" : "2028-9", + "display" : "Asian" + } + }, + { + "url" : "detailed", + "valueCoding" : { + "system" : "urn:oid:2.16.840.1.113883.6.238", + "code" : "2036-2", + "display" : "Filipino" + } + }, + { + "url" : "text", + "valueString" : "Mixed" + }], + "url" : "http://hl7.org/fhir/us/core/StructureDefinition/us-core-race" + }, + { + "extension" : [{ + "url" : "ombCategory", + "valueCoding" : { + "system" : "urn:oid:2.16.840.1.113883.6.238", + "code" : "2135-2", + "display" : "Hispanic or Latino" + } + }, + { + "url" : "detailed", + "valueCoding" : { + "system" : "urn:oid:2.16.840.1.113883.6.238", + "code" : "2148-5", + "display" : "Mexican" + } + }, + { + "url" : "text", + "valueString" : "Hispanic or Latino" + }], + "url" : "http://hl7.org/fhir/us/core/StructureDefinition/us-core-ethnicity" + }, + { + "url" : "http://hl7.org/fhir/us/core/StructureDefinition/us-core-birthsex", + "valueCode" : "F" + }], + "identifier" : [{ + "use" : "usual", + "type" : { + "coding" : [{ + "system" : "http://terminology.hl7.org/CodeSystem/v2-0203", + "code" : "MR" + }], + "text" : "Medical Record Number" + }, + "system" : "http://hospital.smarthealthit.org", + "value" : "1032702" + }], + "active" : true, + "name" : [{ + "family" : "Shaw", + "given" : ["Amy", + "V."], + "period" : { + "start" : "2016-12-06", + "end" : "2020-07-22" + } + }, + { + "family" : "Baxter", + "given" : ["Amy", + "V."], + "suffix" : ["PharmD"], + "period" : { + "start" : "2020-07-22" + } + }], + "telecom" : [{ + "system" : "phone", + "value" : "555-555-5555", + "use" : "home" + }, + { + "system" : "email", + "value" : "amy.shaw@example.com" + }], + "gender" : "female", + "birthDate" : "1987-02-20", + "address" : [{ + "line" : ["49 Meadow St"], + "city" : "Mounds", + "state" : "OK", + "postalCode" : "74047", + "country" : "US", + "period" : { + "start" : "2016-12-06", + "end" : "2020-07-22" + } + }, + { + "line" : ["183 Mountain View St"], + "city" : "Mounds", + "state" : "OK", + "postalCode" : "74048", + "country" : "US", + "period" : { + "start" : "2020-07-22" + } + }] +} \ No newline at end of file diff --git a/spec/fixtures/crd_practitioner_example.json b/spec/fixtures/crd_practitioner_example.json new file mode 100644 index 0000000..ab1b638 --- /dev/null +++ b/spec/fixtures/crd_practitioner_example.json @@ -0,0 +1,48 @@ +{ + "resourceType" : "Practitioner", + "id" : "example", + "text" : { + "status" : "generated", + "div" : "

Generated Narrative: Practitioner

Resource Practitioner "example"

identifier: id:\u00a0#9941339108, id:\u00a025456

name: Adam Careful

address: 1003 Healthcare Drive Amherst MA 01002 (HOME)

Qualifications

-IdentifierCodePeriodIssuer
*id:\u00a012345Bachelor of Science (degreeLicenseCertificate#BS)1995 --> (ongoing): Example University
" + }, + "identifier" : [{ + "system" : "http://hl7.org/fhir/sid/us-npi", + "value" : "9941339108" + }, + { + "system" : "http://www.acme.org/practitioners", + "value" : "25456" + }], + "name" : [{ + "family" : "Careful", + "given" : ["Adam"], + "prefix" : ["Dr"] + }], + "address" : [{ + "use" : "home", + "line" : ["1003 Healthcare Drive"], + "city" : "Amherst", + "state" : "MA", + "postalCode" : "01002" + }], + "qualification" : [{ + "identifier" : [{ + "system" : "http://example.org/UniversityIdentifier", + "value" : "12345" + }], + "code" : { + "coding" : [{ + "system" : "http://terminology.hl7.org/CodeSystem/v2-0360", + "code" : "BS", + "display" : "Bachelor of Science" + }], + "text" : "Bachelor of Science" + }, + "period" : { + "start" : "1995" + }, + "issuer" : { + "display" : "Example University" + } + }] +} \ No newline at end of file diff --git a/spec/fixtures/crd_service_request_example.json b/spec/fixtures/crd_service_request_example.json new file mode 100644 index 0000000..f8a6db8 --- /dev/null +++ b/spec/fixtures/crd_service_request_example.json @@ -0,0 +1,39 @@ +{ + "resourceType" : "ServiceRequest", + "id" : "example", + "text" : { + "status" : "generated", + "div" : "

Generated Narrative: ServiceRequest

Resource ServiceRequest "example"

basedOn: http://example.org/fhir/ServiceRequest/someReferral

status: draft

intent: order

code: Implant Pacemaker (SNOMED CT#25267002 "Insertion of intracardiac pacemaker (procedure)")

subject: Patient/example " SHAW"

authoredOn: 2015-03-30

requester: Practitioner/example: Dr. Beverly Crusher " CAREFUL"

performer: http://example.org/fhir/Practitioner/example2: Dr Cecil Surgeon

locationReference: Location/example "South Wing, second floor"

reasonCode: Bradycardia ()

" + }, + "basedOn" : [{ + "reference" : "http://example.org/fhir/ServiceRequest/someReferral" + }], + "status" : "draft", + "intent" : "order", + "code" : { + "coding" : [{ + "system" : "http://snomed.info/sct", + "code" : "25267002", + "display" : "Insertion of intracardiac pacemaker (procedure)" + }], + "text" : "Implant Pacemaker" + }, + "subject" : { + "reference" : "Patient/example" + }, + "authoredOn" : "2015-03-30", + "requester" : { + "reference" : "Practitioner/example", + "display" : "Dr. Beverly Crusher" + }, + "performer" : [{ + "reference" : "http://example.org/fhir/Practitioner/example2", + "display" : "Dr Cecil Surgeon" + }], + "locationReference" : [{ + "reference" : "Location/example" + }], + "reasonCode" : [{ + "text" : "Bradycardia" + }] +} \ No newline at end of file diff --git a/spec/fixtures/crd_task_example.json b/spec/fixtures/crd_task_example.json new file mode 100644 index 0000000..762c558 --- /dev/null +++ b/spec/fixtures/crd_task_example.json @@ -0,0 +1,85 @@ +{ + "resourceType": "Task", + "id": "questionnaire-example", + "text": { + "status": "generated", + "div": "\u003Cdiv xmlns=\"http://www.w3.org/1999/xhtml\"\u003E\u003Cp\u003E\u003Cb\u003EGenerated Narrative: Task\u003C/b\u003E\u003Ca name=\"questionnaire-example\"\u003E \u003C/a\u003E\u003C/p\u003E\u003Cdiv style=\"display: inline-block; background-color: #d9e0e7; padding: 6px; margin: 4px; border: 1px solid #8da1b4; border-radius: 5px; line-height: 60%\"\u003E\u003Cp style=\"margin-bottom: 0px\"\u003EResource Task "questionnaire-example" \u003C/p\u003E\u003C/div\u003E\u003Cp\u003E\u003Cb\u003EbasedOn\u003C/b\u003E: \u003Ca href=\"MedicationRequest-example.html\"\u003EMedicationRequest/example\u003C/a\u003E\u003C/p\u003E\u003Cp\u003E\u003Cb\u003Estatus\u003C/b\u003E: ready\u003C/p\u003E\u003Cp\u003E\u003Cb\u003Eintent\u003C/b\u003E: order\u003C/p\u003E\u003Cp\u003E\u003Cb\u003Ecode\u003C/b\u003E: Complete Questionnaire \u003Cspan style=\"background: LightGoldenRodYellow; margin: 4px; border: 1px solid khaki\"\u003E (\u003Ca href=\"http://hl7.org/fhir/uv/sdc/STU3/CodeSystem-temp.html\"\u003ETemporary SDC Codes\u003C/a\u003E#complete-questionnaire)\u003C/span\u003E\u003C/p\u003E\u003Cp\u003E\u003Cb\u003Efor\u003C/b\u003E: \u003Ca href=\"Patient-example.html\"\u003EPatient/example\u003C/a\u003E " SHAW"\u003C/p\u003E\u003Cp\u003E\u003Cb\u003Eencounter\u003C/b\u003E: \u003Ca href=\"Encounter-example.html\"\u003EEncounter/example\u003C/a\u003E\u003C/p\u003E\u003Cp\u003E\u003Cb\u003EauthoredOn\u003C/b\u003E: 2018-08-09\u003C/p\u003E\u003Cp\u003E\u003Cb\u003Erequester\u003C/b\u003E: \u003Ca href=\"http://example.org/fhir/Organization/payer\"\u003Ehttp://example.org/fhir/Organization/payer\u003C/a\u003E\u003C/p\u003E\u003Cp\u003E\u003Cb\u003EreasonCode\u003C/b\u003E: Needed for prior authorization \u003Cspan style=\"background: LightGoldenRodYellow; margin: 4px; border: 1px solid khaki\"\u003E (\u003Ca href=\"CodeSystem-temp.html\"\u003ECRD Temporary Codes\u003C/a\u003E#reason-prior-auth)\u003C/span\u003E\u003C/p\u003E\u003Cblockquote\u003E\u003Cp\u003E\u003Cb\u003Einput\u003C/b\u003E\u003C/p\u003E\u003Cp\u003E\u003Cb\u003Etype\u003C/b\u003E: Questionnaire \u003Cspan style=\"background: LightGoldenRodYellow; margin: 4px; border: 1px solid khaki\"\u003E (\u003Ca href=\"http://hl7.org/fhir/uv/sdc/STU3/CodeSystem-temp.html\"\u003ETemporary SDC Codes\u003C/a\u003E#questionnaire)\u003C/span\u003E\u003C/p\u003E\u003Cp\u003E\u003Cb\u003Evalue\u003C/b\u003E: \u003Ca href=\"http://example.org/Questionnaire/XYZ\"\u003Ehttp://example.org/Questionnaire/XYZ|2\u003C/a\u003E\u003C/p\u003E\u003C/blockquote\u003E\u003Cblockquote\u003E\u003Cp\u003E\u003Cb\u003Einput\u003C/b\u003E\u003C/p\u003E\u003Cp\u003E\u003Cb\u003Etype\u003C/b\u003E: Response Endpoint \u003Cspan style=\"background: LightGoldenRodYellow; margin: 4px; border: 1px solid khaki\"\u003E (\u003Ca href=\"http://hl7.org/fhir/uv/sdc/STU3/CodeSystem-temp.html\"\u003ETemporary SDC Codes\u003C/a\u003E#response-endpoint)\u003C/span\u003E\u003C/p\u003E\u003Cp\u003E\u003Cb\u003Evalue\u003C/b\u003E: \u003Ca href=\"http://example.org/somePayer\"\u003Ehttp://example.org/somePayer\u003C/a\u003E\u003C/p\u003E\u003C/blockquote\u003E\u003Cblockquote\u003E\u003Cp\u003E\u003Cb\u003Einput\u003C/b\u003E\u003C/p\u003E\u003Cp\u003E\u003Cb\u003Etype\u003C/b\u003E: After-completion action \u003Cspan style=\"background: LightGoldenRodYellow; margin: 4px; border: 1px solid khaki\"\u003E (\u003Ca href=\"CodeSystem-temp.html\"\u003ECRD Temporary Codes\u003C/a\u003E#after-completion-action)\u003C/span\u003E\u003C/p\u003E\u003Cp\u003E\u003Cb\u003Evalue\u003C/b\u003E: Include in prior authorization \u003Cspan style=\"background: LightGoldenRodYellow; margin: 4px; border: 1px solid khaki\"\u003E (\u003Ca href=\"CodeSystem-temp.html\"\u003ECRD Temporary Codes\u003C/a\u003E#prior-auth-include)\u003C/span\u003E\u003C/p\u003E\u003C/blockquote\u003E\u003C/div\u003E" + }, + "basedOn": [ + { + "reference": "MedicationRequest/example" + } + ], + "status": "ready", + "intent": "order", + "code": { + "coding": [ + { + "system": "http://hl7.org/fhir/uv/sdc/CodeSystem/temp", + "code": "complete-questionnaire" + } + ] + }, + "for": { + "reference": "Patient/example" + }, + "encounter": { + "reference": "Encounter/example" + }, + "authoredOn": "2018-08-09", + "requester": { + "reference": "http://example.org/fhir/Organization/payer" + }, + "reasonCode": { + "coding": [ + { + "system": "http://hl7.org/fhir/us/davinci-crd/CodeSystem/temp", + "code": "reason-prior-auth" + } + ], + "text": "Needed for prior authorization" + }, + "input": [ + { + "type": { + "coding": [ + { + "system": "http://hl7.org/fhir/uv/sdc/CodeSystem/temp", + "code": "questionnaire" + } + ] + }, + "valueCanonical": "http://example.org/Questionnaire/XYZ|2" + }, + { + "type": { + "coding": [ + { + "system": "http://hl7.org/fhir/uv/sdc/CodeSystem/temp", + "code": "response-endpoint" + } + ] + }, + "valueUrl": "http://example.org/somePayer" + }, + { + "type": { + "coding": [ + { + "system": "http://hl7.org/fhir/us/davinci-crd/CodeSystem/temp", + "code": "after-completion-action" + } + ] + }, + "valueCodeableConcept": { + "coding": [ + { + "system": "http://hl7.org/fhir/us/davinci-crd/CodeSystem/temp", + "code": "prior-auth-include", + "display": "Include in prior authorization" + } + ] + } + } + ] +} \ No newline at end of file diff --git a/spec/fixtures/encounter_discharge_hook_request.json b/spec/fixtures/encounter_discharge_hook_request.json new file mode 100644 index 0000000..4515290 --- /dev/null +++ b/spec/fixtures/encounter_discharge_hook_request.json @@ -0,0 +1,17 @@ +{ + "hookInstance": "d1577c69-dfbe-44ad-ba6d-3e05e953b2ea", + "fhirServer": "https://example/r4", + "hook": "encounter-discharge", + "fhirAuthorization": { + "access_token": "SAMPLE_TOKEN", + "token_type": "Bearer", + "expires_in": 300, + "scope": "user/Patient.read user/Observation.read", + "subject": "cds-service" + }, + "context": { + "userId": "Practitioner/example", + "patientId": "example", + "encounterId": "example" + } + } \ No newline at end of file diff --git a/spec/fixtures/encounter_start_hook_request.json b/spec/fixtures/encounter_start_hook_request.json new file mode 100644 index 0000000..1d25dbf --- /dev/null +++ b/spec/fixtures/encounter_start_hook_request.json @@ -0,0 +1,17 @@ +{ + "hookInstance": "d1577c69-dfbe-44ad-ba6d-3e05e953b2ea", + "fhirServer": "https://example/r4", + "hook": "encounter-start", + "fhirAuthorization": { + "access_token": "SAMPLE_TOKEN", + "token_type": "Bearer", + "expires_in": 300, + "scope": "user/Patient.read user/Observation.read", + "subject": "cds-service" + }, + "context": { + "userId": "Practitioner/example", + "patientId": "example", + "encounterId": "example" + } + } \ No newline at end of file diff --git a/spec/fixtures/order_dispatch_hook_request.json b/spec/fixtures/order_dispatch_hook_request.json new file mode 100644 index 0000000..3505136 --- /dev/null +++ b/spec/fixtures/order_dispatch_hook_request.json @@ -0,0 +1,44 @@ +{ + "hookInstance": "d1577c69-dfbe-44ad-ba6d-3e05e953b2ea", + "fhirServer": "https://example/r4", + "hook": "order-dispatch", + "fhirAuthorization": { + "access_token": "SAMPLE_TOKEN", + "token_type": "Bearer", + "expires_in": 300, + "scope": "user/Patient.read user/Observation.read", + "subject": "cds-service" + }, + "context": { + "patientId": "example", + "performer": "Practitioner/example", + "order": "ServiceRequest/example", + "task": { + "resourceType" : "Task", + "id" : "example3", + "text" : { + "status" : "generated", + "div" : "

Generated Narrative: Task

Resource Task "example3"

status: draft

intent: order

code: Refill Request ()

focus: MedicationRequest/medrx002

for: Patient/f001 "Pieter VAN DE HEUVEL"

authoredOn: 2016-03-10T22:39:32-04:00

lastModified: 2016-03-10T22:39:32-04:00

requester: Patient/example "Peter CHALMERS"

owner: Practitioner/example "Adam CAREFUL"

" + }, + "status" : "draft", + "intent" : "order", + "code" : { + "text" : "Refill Request" + }, + "focus" : { + "reference" : "MedicationRequest/medrx002" + }, + "for" : { + "reference" : "Patient/f001" + }, + "authoredOn" : "2016-03-10T22:39:32-04:00", + "lastModified" : "2016-03-10T22:39:32-04:00", + "requester" : { + "reference" : "Patient/example" + }, + "owner" : { + "reference" : "Practitioner/example" + } + } + } + } \ No newline at end of file diff --git a/spec/fixtures/order_select_context.json b/spec/fixtures/order_select_context.json new file mode 100644 index 0000000..5a926a2 --- /dev/null +++ b/spec/fixtures/order_select_context.json @@ -0,0 +1,334 @@ +{ + "userId": "Practitioner/123", + "patientId": "1288992", + "encounterId": "89284", + "selections": [ + "NutritionOrder/pureeddiet-simple", + "MedicationRequest/smart-MedicationRequest-103" + ], + "draftOrders": { + "resourceType": "Bundle", + "entry": [ + { + "resource": { + "resourceType": "NutritionOrder", + "id": "pureeddiet-simple", + "text": { + "status": "generated", + "div": "

Generated Narrative: NutritionOrder

Resource NutritionOrder "example"

identifier: http://goodhealthhospital.org/nutrition-requests/123

status: draft

intent: order

patient: Patient/example " SHAW"

encounter: Encounter/example

dateTime: 2014-09-17

orderer: Practitioner/example " CAREFUL"

allergyIntolerance: http://example.org/fhir/AllergyIntolerance/example: Cashew Nuts

foodPreferenceModifier: Dairy Free (Diet#dairy-free)

excludeFoodModifier: Cashew Nut (SNOMED CT#227493005)

oralDiet

type: Fiber restricted diet (SNOMED CT#15108003 "Restricted fiber diet"), Low fat diet (SNOMED CT#16208003)

schedule: Starting 2015-02-10, 3 per 1 days

Nutrients

-ModifierAmount
*Fat (SNOMED CT#256674009)50 grams (Details: UCUM code g = 'g')
" + }, + "identifier": [ + { + "system": "http://goodhealthhospital.org/nutrition-requests", + "value": "123" + } + ], + "status": "draft", + "intent": "order", + "patient": { + "reference": "Patient/1288992" + }, + "encounter": { + "reference": "Encounter/89284" + }, + "dateTime": "2014-09-17", + "orderer": { + "reference": "Practitioner/1234" + }, + "allergyIntolerance": [ + { + "reference": "http://example.org/fhir/AllergyIntolerance/example", + "display": "Cashew Nuts" + } + ], + "foodPreferenceModifier": [ + { + "coding": [ + { + "system": "http://terminology.hl7.org/CodeSystem/diet", + "code": "dairy-free" + } + ] + } + ], + "excludeFoodModifier": [ + { + "coding": [ + { + "system": "http://snomed.info/sct", + "code": "227493005", + "display": "Cashew Nut" + } + ] + } + ], + "oralDiet": { + "type": [ + { + "coding": [ + { + "system": "http://snomed.info/sct", + "code": "15108003", + "display": "Restricted fiber diet" + } + ], + "text": "Fiber restricted diet" + }, + { + "coding": [ + { + "system": "http://snomed.info/sct", + "code": "16208003", + "display": "Low fat diet" + } + ], + "text": "Low fat diet" + } + ], + "schedule": [ + { + "repeat": { + "boundsPeriod": { + "start": "2015-02-10" + }, + "frequency": 3, + "period": 1, + "periodUnit": "d" + } + } + ], + "nutrient": [ + { + "modifier": { + "coding": [ + { + "system": "http://snomed.info/sct", + "code": "256674009", + "display": "Fat" + } + ] + }, + "amount": { + "value": 50, + "unit": "grams", + "system": "http://unitsofmeasure.org", + "code": "g" + } + } + ] + } + } + }, + { + "resource": { + "resourceType": "MedicationRequest", + "id": "smart-MedicationRequest-103", + "text": { + "status": "generated", + "div": "

Generated Narrative: MedicationRequest

Resource MedicationRequest "example"

identifier: http://www.bmc.nl/portal/prescriptions/12345689 (use: OFFICIAL)

status: draft

intent: order

medication:

code: Azithromycin 250 mg oral tablet (SNOMED CT#1145423002)

subject: Patient/example " SHAW"

encounter: Encounter/example

authoredOn: 2015-01-15

requester: Practitioner/example " CAREFUL"

reasonCode: Traveler's Diarrhea (disorder) (SNOMED CT#11840006)

insurance: Coverage/example

note: Patient told to take with food

dosageInstruction

sequence: 1

text: Two tablets at once

additionalInstruction: With or after food (SNOMED CT#311504000)

timing: Once per 1 days

route: Oral Route (SNOMED CT#26643006)

method: Swallow - dosing instruction imperative (qualifier value) (SNOMED CT#421521009)

doseAndRate

dosageInstruction

sequence: 2

text: One tablet daily for 4 days

additionalInstruction: With or after food (SNOMED CT#311504000)

timing: 4 per 1 days

route: Oral Route (SNOMED CT#26643006)

doseAndRate

dispenseRequest

validityPeriod: 2015-01-15 --> 2016-01-15

numberOfRepeatsAllowed: 1

quantity: 6 TAB (Details: http://terminology.hl7.org/CodeSystem/v3-orderableDrugForm code TAB = 'Tablet')

ExpectedSupplyDurations

-ValueUnitSystemCode
*5daysUnified Code for Units of Measure (UCUM)d

Substitutions

-Allowed[x]Reason
*trueformulary policy (ActReason#FP)

Generated Narrative: Medication #med0320

code: Azithromycin 250 mg oral tablet (SNOMED CT#1145423002)

" + }, + "contained": [ + { + "resourceType": "Medication", + "id": "med0320", + "code": { + "coding": [ + { + "system": "http://snomed.info/sct", + "code": "1145423002", + "display": "Azithromycin 250 mg oral tablet" + } + ] + } + } + ], + "identifier": [ + { + "use": "official", + "system": "http://www.bmc.nl/portal/prescriptions", + "value": "12345689" + } + ], + "status": "draft", + "intent": "order", + "medicationReference": { + "reference": "#med0320" + }, + "subject": { + "reference": "Patient/1288992" + }, + "encounter": { + "reference": "Encounter/89284" + }, + "authoredOn": "2015-01-15", + "requester": { + "reference": "Practitioner/1234" + }, + "reasonCode": [ + { + "coding": [ + { + "system": "http://snomed.info/sct", + "code": "11840006", + "display": "Traveler's Diarrhea (disorder)" + } + ] + } + ], + "insurance": [ + { + "reference": "Coverage/example" + } + ], + "note": [ + { + "text": "Patient told to take with food" + } + ], + "dosageInstruction": [ + { + "sequence": 1, + "text": "Two tablets at once", + "additionalInstruction": [ + { + "coding": [ + { + "system": "http://snomed.info/sct", + "code": "311504000", + "display": "With or after food" + } + ] + } + ], + "timing": { + "repeat": { + "frequency": 1, + "period": 1, + "periodUnit": "d" + } + }, + "route": { + "coding": [ + { + "system": "http://snomed.info/sct", + "code": "26643006", + "display": "Oral Route" + } + ] + }, + "method": { + "coding": [ + { + "system": "http://snomed.info/sct", + "code": "421521009", + "display": "Swallow - dosing instruction imperative (qualifier value)" + } + ] + }, + "doseAndRate": [ + { + "type": { + "coding": [ + { + "system": "http://terminology.hl7.org/CodeSystem/dose-rate-type", + "code": "ordered", + "display": "Ordered" + } + ] + }, + "doseQuantity": { + "value": 2, + "unit": "TAB", + "system": "http://terminology.hl7.org/CodeSystem/v3-orderableDrugForm", + "code": "TAB" + } + } + ] + }, + { + "sequence": 2, + "text": "One tablet daily for 4 days", + "additionalInstruction": [ + { + "coding": [ + { + "system": "http://snomed.info/sct", + "code": "311504000", + "display": "With or after food" + } + ] + } + ], + "timing": { + "repeat": { + "frequency": 4, + "period": 1, + "periodUnit": "d" + } + }, + "route": { + "coding": [ + { + "system": "http://snomed.info/sct", + "code": "26643006", + "display": "Oral Route" + } + ] + }, + "doseAndRate": [ + { + "type": { + "coding": [ + { + "system": "http://terminology.hl7.org/CodeSystem/dose-rate-type", + "code": "ordered", + "display": "Ordered" + } + ] + }, + "doseQuantity": { + "value": 1, + "unit": "TAB", + "system": "http://terminology.hl7.org/CodeSystem/v3-orderableDrugForm", + "code": "TAB" + } + } + ] + } + ], + "dispenseRequest": { + "validityPeriod": { + "start": "2015-01-15", + "end": "2016-01-15" + }, + "numberOfRepeatsAllowed": 1, + "quantity": { + "value": 6, + "unit": "TAB", + "system": "http://terminology.hl7.org/CodeSystem/v3-orderableDrugForm", + "code": "TAB" + }, + "expectedSupplyDuration": { + "value": 5, + "unit": "days", + "system": "http://unitsofmeasure.org", + "code": "d" + } + }, + "substitution": { + "allowedBoolean": true, + "reason": { + "coding": [ + { + "system": "http://terminology.hl7.org/CodeSystem/v3-ActReason", + "code": "FP", + "display": "formulary policy" + } + ] + } + } + } + } + ] + } +} diff --git a/spec/fixtures/order_select_hook_request.json b/spec/fixtures/order_select_hook_request.json new file mode 100644 index 0000000..552811c --- /dev/null +++ b/spec/fixtures/order_select_hook_request.json @@ -0,0 +1,187 @@ +{ + "hookInstance": "d1577c69-dfbe-44ad-ba6d-3e05e953b2ea", + "fhirServer": "https://example/r4", + "hook": "order-select", + "fhirAuthorization": { + "access_token": "SAMPLE_TOKEN", + "token_type": "Bearer", + "expires_in": 300, + "scope": "user/Patient.read user/Observation.read", + "subject": "cds-service" + }, + "context":{ + "userId":"Practitioner/example", + "patientId":"example", + "selections": [ "NutritionOrder/pureeddiet-simple", "MedicationRequest/smart-MedicationRequest-103" ], + "draftOrders":{ + "resourceType":"Bundle", + "entry":[ + { + "resource":{ + "resourceType":"NutritionOrder", + "id":"pureeddiet-simple", + "identifier":[ + { + "system":"http://goodhealthhospital.org/nutrition-requests", + "value":"123" + } + ], + "status":"draft", + "patient":{ + "reference":"Patient/1288992" + }, + "dateTime":"2014-09-17", + "orderer":{ + "reference":"Practitioner/example", + "display":"Dr Adam Careful" + }, + "oralDiet":{ + "type":[ + { + "coding":[ + { + "system":"http://snomed.info/sct", + "code":"226211001", + "display":"Pureed diet" + }, + { + "system":"http://goodhealthhospital.org/diet-type-codes", + "code":"1010", + "display":"Pureed diet" + } + ], + "text":"Pureed diet" + } + ], + "schedule":[ + { + "repeat":{ + "boundsPeriod":{ + "start":"2015-02-10" + }, + "frequency":3, + "period":1, + "periodUnit":"d" + } + } + ], + "texture":[ + { + "modifier":{ + "coding":[ + { + "system":"http://snomed.info/sct", + "code":"228055009", + "display":"Liquidized food" + } + ], + "text":"Pureed" + } + } + ], + "fluidConsistencyType":[ + { + "coding":[ + { + "system":"http://snomed.info/sct", + "code":"439021000124105", + "display":"Dietary liquid consistency - nectar thick liquid" + } + ], + "text":"Nectar thick liquids" + } + ] + }, + "supplement":[ + { + "type":{ + "coding":[ + { + "system":"http://snomed.info/sct", + "code":"442971000124100", + "display":"Adult high energy formula" + }, + { + "system":"http://goodhealthhospital.org/supplement-type-codes", + "code":"1040", + "display":"Adult high energy pudding" + } + ], + "text":"Adult high energy pudding" + }, + "productName":"Ensure Pudding 4 oz container", + "instruction":"Ensure Pudding at breakfast, lunch, supper" + } + ] + } + }, + { + "resource":{ + "resourceType":"MedicationRequest", + "id":"smart-MedicationRequest-103", + "meta":{ + "lastUpdated":"2018-04-30T13:25:40.845-04:00" + }, + "text":{ + "status":"generated", + "div":"
Amoxicillin 120 MG/ML / clavulanate potassium 8.58 MG/ML Oral Suspension (rxnorm: 617993)
" + }, + "status":"draft", + "intent":"order", + "medicationCodeableConcept":{ + "coding":[ + { + "system":"http://www.nlm.nih.gov/research/umls/rxnorm", + "code":"617993", + "display":"Amoxicillin 120 MG/ML / clavulanate potassium 8.58 MG/ML Oral Suspension" + } + ], + "text":"Amoxicillin 120 MG/ML / clavulanate potassium 8.58 MG/ML Oral Suspension" + }, + "subject":{ + "reference":"Patient/1288992" + }, + "dosageInstruction":[ + { + "text":"5 mL bid x 10 days", + "timing":{ + "repeat":{ + "boundsPeriod":{ + "start":"2005-01-04" + }, + "frequency":2, + "period":1, + "periodUnit":"d" + } + }, + "doseAndRate":{ + "doseQuantity":{ + "value":5, + "unit":"mL", + "system":"http://unitsofmeasure.org", + "code":"mL" + } + } + } + ], + "dispenseRequest":{ + "numberOfRepeatsAllowed":1, + "quantity":{ + "value":1, + "unit":"mL", + "system":"http://unitsofmeasure.org", + "code":"mL" + }, + "expectedSupplyDuration":{ + "value":10, + "unit":"days", + "system":"http://unitsofmeasure.org", + "code":"d" + } + } + } + } + ] + } + } + } \ No newline at end of file diff --git a/spec/fixtures/order_sign_hook_request.json b/spec/fixtures/order_sign_hook_request.json new file mode 100644 index 0000000..ea4ed74 --- /dev/null +++ b/spec/fixtures/order_sign_hook_request.json @@ -0,0 +1,186 @@ +{ + "hookInstance": "d1577c69-dfbe-44ad-ba6d-3e05e953b2ea", + "fhirServer": "https://example/r4", + "hook": "order-sign", + "fhirAuthorization": { + "access_token": "SAMPLE_TOKEN", + "token_type": "Bearer", + "expires_in": 300, + "scope": "user/Patient.read user/Observation.read", + "subject": "cds-service" + }, + "context":{ + "userId":"Practitioner/example", + "patientId":"example", + "draftOrders":{ + "resourceType":"Bundle", + "entry":[ + { + "resource":{ + "resourceType":"NutritionOrder", + "id":"pureeddiet-simple", + "identifier":[ + { + "system":"http://goodhealthhospital.org/nutrition-requests", + "value":"123" + } + ], + "status":"draft", + "patient":{ + "reference":"Patient/1288992" + }, + "dateTime":"2014-09-17", + "orderer":{ + "reference":"Practitioner/example", + "display":"Dr Adam Careful" + }, + "oralDiet":{ + "type":[ + { + "coding":[ + { + "system":"http://snomed.info/sct", + "code":"226211001", + "display":"Pureed diet" + }, + { + "system":"http://goodhealthhospital.org/diet-type-codes", + "code":"1010", + "display":"Pureed diet" + } + ], + "text":"Pureed diet" + } + ], + "schedule":[ + { + "repeat":{ + "boundsPeriod":{ + "start":"2015-02-10" + }, + "frequency":3, + "period":1, + "periodUnit":"d" + } + } + ], + "texture":[ + { + "modifier":{ + "coding":[ + { + "system":"http://snomed.info/sct", + "code":"228055009", + "display":"Liquidized food" + } + ], + "text":"Pureed" + } + } + ], + "fluidConsistencyType":[ + { + "coding":[ + { + "system":"http://snomed.info/sct", + "code":"439021000124105", + "display":"Dietary liquid consistency - nectar thick liquid" + } + ], + "text":"Nectar thick liquids" + } + ] + }, + "supplement":[ + { + "type":{ + "coding":[ + { + "system":"http://snomed.info/sct", + "code":"442971000124100", + "display":"Adult high energy formula" + }, + { + "system":"http://goodhealthhospital.org/supplement-type-codes", + "code":"1040", + "display":"Adult high energy pudding" + } + ], + "text":"Adult high energy pudding" + }, + "productName":"Ensure Pudding 4 oz container", + "instruction":"Ensure Pudding at breakfast, lunch, supper" + } + ] + } + }, + { + "resource":{ + "resourceType":"MedicationRequest", + "id":"smart-MedicationRequest-103", + "meta":{ + "lastUpdated":"2018-04-30T13:25:40.845-04:00" + }, + "text":{ + "status":"generated", + "div":"
Amoxicillin 120 MG/ML / clavulanate potassium 8.58 MG/ML Oral Suspension (rxnorm: 617993)
" + }, + "status":"draft", + "intent":"order", + "medicationCodeableConcept":{ + "coding":[ + { + "system":"http://www.nlm.nih.gov/research/umls/rxnorm", + "code":"617993", + "display":"Amoxicillin 120 MG/ML / clavulanate potassium 8.58 MG/ML Oral Suspension" + } + ], + "text":"Amoxicillin 120 MG/ML / clavulanate potassium 8.58 MG/ML Oral Suspension" + }, + "subject":{ + "reference":"Patient/1288992" + }, + "dosageInstruction":[ + { + "text":"5 mL bid x 10 days", + "timing":{ + "repeat":{ + "boundsPeriod":{ + "start":"2005-01-04" + }, + "frequency":2, + "period":1, + "periodUnit":"d" + } + }, + "doseAndRate":{ + "doseQuantity":{ + "value":5, + "unit":"mL", + "system":"http://unitsofmeasure.org", + "code":"mL" + } + } + } + ], + "dispenseRequest":{ + "numberOfRepeatsAllowed":1, + "quantity":{ + "value":1, + "unit":"mL", + "system":"http://unitsofmeasure.org", + "code":"mL" + }, + "expectedSupplyDuration":{ + "value":10, + "unit":"days", + "system":"http://unitsofmeasure.org", + "code":"d" + } + } + } + } + ] + } + } + } \ No newline at end of file diff --git a/spec/fixtures/other_system_action.json b/spec/fixtures/other_system_action.json new file mode 100644 index 0000000..16def8a --- /dev/null +++ b/spec/fixtures/other_system_action.json @@ -0,0 +1,49 @@ +{ + "type": "update", + "resource": { + "resourceType": "ServiceRequest", + "id": "example-MRI-59879846", + "extension": [ + { + "url": "http://fhir.org/argonaut/Extension/pama-rating", + "valueCodeableConcept": { + "coding": [ + { + "system": "http://fhir.org/argonaut/CodeSystem/pama-rating", + "code": "appropriate" + } + ] + } + }, + { + "url": "http://fhir.org/argonaut/Extension/pama-rating-consult-id", + "valueUri": "urn:uuid:55f3b7fc-9955-420e-a460-ff284b2956e6" + } + ], + "status": "draft", + "intent": "plan", + "code": { + "coding": [ + { + "system": "http://loinc.org", + "code": "36801-9" + } + ], + "text": "MRA Knee Vessels Right" + }, + "subject": { + "reference": "Patient/MRI-59879846" + }, + "reasonCode": [ + { + "coding": [ + { + "system": "http://hl7.org/fhir/sid/icd-10", + "code": "S83.511", + "display": "Sprain of anterior cruciate ligament of right knee" + } + ] + } + ] + } +} diff --git a/spec/fixtures/valid_cards.json b/spec/fixtures/valid_cards.json new file mode 100644 index 0000000..832dd7c --- /dev/null +++ b/spec/fixtures/valid_cards.json @@ -0,0 +1,511 @@ +[ + { + "summary": "Order Select External Reference Card", + "uuid": "jksfjuisldklsior", + "detail": "This is an External Reference Card containing one or more links to external web pages, PDFs, or other resources that provide relevant coverage information.", + "indicator": "info", + "source": { + "label": "Inferno", + "url": "https://inferno.healthit.gov/", + "topic": { + "system": "http://hl7.org/fhir/us/davinci-crd/CodeSystem/temp", + "code": "order-select", + "display": "Order Select" + } + }, + "links": [ + { + "label": "CRD IG External Reference Card Info", + "url": "https://build.fhir.org/ig/HL7/davinci-crd/cards.html#external-reference", + "type": "absolute" + } + ] + }, + { + "summary": "Order Select Launch SMART Application Card", + "uuid": "jksfjuisldklsior", + "detail": "This is a Launch SMART Application Card containing one or more links.", + "indicator": "info", + "source": { + "label": "Some Payer", + "url": "https://example.com", + "icon": "https://example.com/img/icon-100px.png", + "topic": { + "system": "http://hl7.org/fhir/us/davinci-crd/CodeSystem/temp", + "code": "guideline", + "display": "Guideline" + } + }, + "links": [ + { + "label": "Opioid XYZ-assessment", + "url": "https://example.org/opioid-assessment", + "type": "smart", + "appContext": "{\"payerXYZQNum\":\"205f471f-f408-45d4-9213-0eedf95f417f\"}" + } + ] + }, + { + "summary": "Order Select Resjection Card", + "uuid": "jksfjuisldlrllke", + "detail": "This is a Rejection Card containing one or more reasons for rejecting a card.", + "indicator": "info", + "source": { + "label": "Inferno", + "url": "https://inferno.healthit.gov/", + "topic": { + "system": "http://hl7.org/fhir/us/davinci-crd/CodeSystem/temp", + "code": "order-select", + "display": "Order Select" + } + }, + "overrideReasons": [ + { + "code": "reason-code-provided-by-service", + "system": "http://example.org/cds-services/fhir/CodeSystem/override-reasons", + "display": "Patient refused" + }, + { + "code": "12354", + "system": "http://example.org/cds-services/fhir/CodeSystem/override-reasons", + "display": "Contraindicated" + } + ] + }, + { + "summary": "Order Select Propose Alternate Request Card", + "uuid": "jksfjuisldlrlfjre", + "detail": "This is a Propose Alternate Request Card containing one or more suggestions.", + "indicator": "info", + "source": { + "label": "Inferno", + "url": "https://inferno.healthit.gov/", + "topic": { + "system": "http://hl7.org/fhir/us/davinci-crd/CodeSystem/temp", + "code": "order-select", + "display": "Order Select" + } + }, + "selectionBehavior": "any", + "suggestions": [ + { + "label": "Change to lower price name brand (selected name brand not covered)", + "actions": [ + { + "type": "delete", + "description": "Remove name-brand prescription", + "resourceId": [ + "MedicationRequest/smart-MedicationRequest-103" + ] + }, + { + "type": "create", + "description": "Add lower-cost alternative", + "resource": { + "resourceType": "MedicationRequest", + "identifier": [ + { + "use": "official", + "system": "http://www.bmc.nl/portal/prescriptions", + "value": "12345689" + } + ], + "status": "draft", + "intent": "order", + "medicationCodeableConcept": { + "coding": [ + { + "system": "http://www.nlm.nih.gov/research/umls/rxnorm", + "code": "1790533", + "display": "Abuse-Deterrent 12 HR oxycodone 9 MG Extended Release Oral Capsule [Xtampza]" + } + ] + }, + "subject": { + "reference": "Patient/1288992" + }, + "encounter": { + "reference": "Encounter/89284" + }, + "authoredOn": "2015-01-15", + "requester": { + "reference": "Practitioner/1234" + }, + "reasonCode": [ + { + "coding": [ + { + "system": "http://snomed.info/sct", + "code": "11840006", + "display": "Traveler's Diarrhea (disorder)" + } + ] + } + ], + "insurance": [ + { + "reference": "Coverage/example" + } + ], + "note": [ + { + "text": "Patient told to take with food" + } + ], + "dosageInstruction": [ + { + "sequence": 1, + "text": "Two tablets at once", + "additionalInstruction": [ + { + "coding": [ + { + "system": "http://snomed.info/sct", + "code": "311504000", + "display": "With or after food" + } + ] + } + ], + "timing": { + "repeat": { + "frequency": 1, + "period": 1, + "periodUnit": "d" + } + }, + "route": { + "coding": [ + { + "system": "http://snomed.info/sct", + "code": "26643006", + "display": "Oral Route" + } + ] + }, + "method": { + "coding": [ + { + "system": "http://snomed.info/sct", + "code": "421521009", + "display": "Swallow - dosing instruction imperative (qualifier value)" + } + ] + }, + "doseAndRate": [ + { + "type": { + "coding": [ + { + "system": "http://terminology.hl7.org/CodeSystem/dose-rate-type", + "code": "ordered", + "display": "Ordered" + } + ] + }, + "doseQuantity": { + "value": 2, + "unit": "TAB", + "system": "http://terminology.hl7.org/CodeSystem/v3-orderableDrugForm", + "code": "TAB" + } + } + ] + }, + { + "sequence": 2, + "text": "One tablet daily for 4 days", + "additionalInstruction": [ + { + "coding": [ + { + "system": "http://snomed.info/sct", + "code": "311504000", + "display": "With or after food" + } + ] + } + ], + "timing": { + "repeat": { + "frequency": 4, + "period": 1, + "periodUnit": "d" + } + }, + "route": { + "coding": [ + { + "system": "http://snomed.info/sct", + "code": "26643006", + "display": "Oral Route" + } + ] + }, + "doseAndRate": [ + { + "type": { + "coding": [ + { + "system": "http://terminology.hl7.org/CodeSystem/dose-rate-type", + "code": "ordered", + "display": "Ordered" + } + ] + }, + "doseQuantity": { + "value": 1, + "unit": "TAB", + "system": "http://terminology.hl7.org/CodeSystem/v3-orderableDrugForm", + "code": "TAB" + } + } + ] + } + ], + "dispenseRequest": { + "validityPeriod": { + "start": "2015-01-15", + "end": "2016-01-15" + }, + "numberOfRepeatsAllowed": 1, + "quantity": { + "value": 6, + "unit": "TAB", + "system": "http://terminology.hl7.org/CodeSystem/v3-orderableDrugForm", + "code": "TAB" + }, + "expectedSupplyDuration": { + "value": 5, + "unit": "days", + "system": "http://unitsofmeasure.org", + "code": "d" + } + }, + "substitution": { + "allowedBoolean": true, + "reason": { + "coding": [ + { + "system": "http://terminology.hl7.org/CodeSystem/v3-ActReason", + "code": "FP", + "display": "formulary policy" + } + ] + } + } + } + } + ] + } + ] + }, + { + "summary": "Order Select Additional Orders As Companions/Prerequisites Card", + "uuid": "jksfjuisldlrldsse", + "detail": "This is a Card containing one or more suggestions.", + "indicator": "info", + "source": { + "label": "Inferno", + "url": "https://inferno.healthit.gov/", + "topic": { + "system": "http://hl7.org/fhir/us/davinci-crd/CodeSystem/temp", + "code": "order-select", + "display": "Order Select" + } + }, + "selectionBehavior": "any", + "suggestions": [ + { + "label": "Add monthly AST test for 1st 3 months", + "actions": [ + { + "type": "create", + "description": "Add order for AST test", + "resource": { + "resourceType": "ServiceRequest", + "status": "draft", + "intent": "original-order", + "category": [ + { + "coding": [ + { + "system": "http://snomed.info/sct", + "code": "108252007", + "display": "Laboratory procedure" + } + ] + } + ], + "code": { + "coding": [ + { + "system": "http://www.ama-assn.org/go/cpt", + "code": "80076", + "display": "Hepatic function panel" + } + ] + }, + "subject": { + "reference": "http://example.org/fhir/Patient/123", + "display": "Jane Smith" + }, + "encounter": { + "reference": "http://example.org/fhir/Encounter/ABC" + }, + "occurrenceTiming": { + "repeat": { + "boundsDuration": { + "value": 3, + "unit": "months", + "system": "http://unitsofmeasure.org", + "code": "mo" + }, + "frequency": 1, + "period": 1, + "periodUnit": "mo" + } + }, + "authoredOn": "2019-02-15", + "requester": { + "reference": "http://example.org/fhir/PractitionerRole/987", + "display": "Dr. Jones" + } + } + } + ] + } + ] + }, + { + "summary": "Order Select Request Form Completion Card", + "uuid": "jksfghisldlrldsse", + "detail": "This is a Card containing one or more suggestions.", + "indicator": "info", + "source": { + "label": "Inferno", + "url": "https://inferno.healthit.gov/", + "topic": { + "system": "http://hl7.org/fhir/us/davinci-crd/CodeSystem/temp", + "code": "order-select", + "display": "Order Select" + } + }, + "selectionBehavior": "any", + "suggestions": [ + { + "label": "Add 'completion of the ABC form' to your task list (possibly for reassignment)", + "actions": [ + { + "type": "create", + "description": "Add 'Complete ABC form' to the task list", + "resource": { + "resourceType": "Task", + "basedOn": [ + { + "reference": "http://example.org/fhir/Appointment/27" + } + ], + "status": "ready", + "intent": "order", + "code": { + "coding": [ + { + "system": "http://hl7.org/fhir/uv/sdc/CodeSystem/temp", + "code": "complete-questionnaire" + } + ] + }, + "description": "Complete XYZ form for local retention", + "for": { + "reference": "http://example.org/fhir/Patient/123" + }, + "authoredOn": "2018-08-09", + "input": [ + { + "type": { + "text": "questionnaire" + }, + "valueCanonical": "http://example.org/Questionnaire/XYZ" + }, + { + "type": { + "text": "afterCompletion" + }, + "valueCodeableConcept": { + "coding": [ + { + "system": "http://example.org/fhir/CodeSystem/SomeCodes", + "code": "987", + "display": "Local Use" + } + ] + } + } + ] + } + } + ] + } + ] + }, + { + "summary": "Order Select Create or Update Coverage Info Card", + "uuid": "jksfghisldlrldsse", + "detail": "This is a Card containing one or more suggestions.", + "indicator": "info", + "source": { + "label": "Inferno", + "url": "https://inferno.healthit.gov/", + "topic": { + "system": "http://hl7.org/fhir/us/davinci-crd/CodeSystem/temp", + "code": "order-select", + "display": "Order Select" + } + }, + "selectionBehavior": "any", + "suggestions": [ + { + "label": "Update coverage information to be current", + "uuid": "urn:uuid:1207df9d-9ff6-4042-985b-b8dec21038c2", + "actions": [ + { + "type": "update", + "description": "Update current coverage record", + "resource": { + "resourceType": "Coverage", + "id": "1234", + "status": "active", + "subscriberId": "192837", + "beneficiary": { + "reference": "http://example.org/fhir/Patient/123" + }, + "period": { + "start": "2023-01-01", + "end": "2023-11-30" + }, + "payor": [ + { + "reference": "http://example.org/fhir/Organization/ABC" + } + ], + "class": [ + { + "type": { + "coding": [ + { + "system": "http://terminology.hl7.org/CodeSystem/coverage-class", + "code": "group" + } + ] + }, + "value": "A1" + } + ] + } + } + ] + } + ] + } +] diff --git a/spec/request_helper.rb b/spec/request_helper.rb new file mode 100644 index 0000000..30e3320 --- /dev/null +++ b/spec/request_helper.rb @@ -0,0 +1,26 @@ +require 'spec_helper' +require 'rack/test' +require 'inferno/apps/web/application' + +module RequestHelpers + def app + Inferno::Web.app + end + + def post_json(path, data) + post path, data.to_json, 'CONTENT_TYPE' => 'application/json' + end + + def parsed_body + JSON.parse(last_response.body) + end +end + +RSpec.configure do |config| + config.define_derived_metadata(file_path: %r{/routes/}) do |metadata| + metadata[:request] = true + end + + config.include Rack::Test::Methods, request: true + config.include RequestHelpers, request: true +end diff --git a/spec/spec_helper.rb b/spec/spec_helper.rb new file mode 100644 index 0000000..f30b83f --- /dev/null +++ b/spec/spec_helper.rb @@ -0,0 +1,141 @@ +# Hide deprecation warnings +$VERBOSE = nil + +ENV['APP_ENV'] ||= 'test' + +require 'database_cleaner/sequel' +require 'pry' +require 'pry-byebug' + +require 'webmock/rspec' +WebMock.disable_net_connect! + +require 'factory_bot' + +# This file was generated by the `rspec --init` command. Conventionally, all +# specs live under a `spec` directory, which RSpec adds to the `$LOAD_PATH`. +# The generated `.rspec` file contains `--require spec_helper` which will cause +# this file to always be loaded, without a need to explicitly require it in any +# files. +# +# Given that it is always loaded, you are encouraged to keep this file as +# light-weight as possible. Requiring heavyweight dependencies from this file +# will add to the boot time of your test suite on EVERY test run, even for an +# individual file that may not need all of that loaded. Instead, consider making +# a separate helper file that requires the additional dependencies and performs +# the additional setup, and require it from the spec files that actually need +# it. +# +# The `.rspec` file also contains a few flags that are not defaults but that +# users commonly want. +# +# See http://rubydoc.info/gems/rspec-core/RSpec/Core/Configuration +RSpec.configure do |config| + # rspec-expectations config goes here. You can use an alternate + # assertion/expectation library such as wrong or the stdlib/minitest + # assertions if you prefer. + config.expect_with :rspec do |expectations| + # This option will default to `true` in RSpec 4. It makes the `description` + # and `failure_message` of custom matchers include text for helper methods + # defined using `chain`, e.g.: + # be_bigger_than(2).and_smaller_than(4).description + # # => "be bigger than 2 and smaller than 4" + # ...rather than: + # # => "be bigger than 2" + expectations.include_chain_clauses_in_custom_matcher_descriptions = true + end + + # rspec-mocks config goes here. You can use an alternate test double + # library (such as bogus or mocha) by changing the `mock_with` option here. + config.mock_with :rspec do |mocks| + # Prevents you from mocking or stubbing a method that does not exist on + # a real object. This is generally recommended, and will default to + # `true` in RSpec 4. + mocks.verify_partial_doubles = true + end + + # Allows RSpec to persist some state between runs in order to support + # the `--only-failures` and `--next-failure` CLI options. We recommend + # you configure your source control system to ignore this file. + config.example_status_persistence_file_path = 'spec/examples.txt' + + # Limits the available syntax to the non-monkey patched syntax that is + # recommended. For more details, see: + # - http://myronmars.to/n/dev-blog/2012/06/rspecs-new-expectation-syntax + # - http://teaisaweso.me/blog/2013/05/27/rspecs-new-message-expectation-syntax/ + # - http://myronmars.to/n/dev-blog/2014/05/notable-changes-in-rspec-3#new__config_option_to_disable_rspeccore_monkey_patching + config.disable_monkey_patching! + + # Many RSpec users commonly either run the entire suite or an individual + # file, and it's useful to allow more verbose output when running an + # individual spec file. + if config.files_to_run.one? + # Use the documentation formatter for detailed output, + # unless a formatter has already been configured + # (e.g. via a command-line flag). + config.default_formatter = 'doc' + end + + # Run specs in random order to surface order dependencies. If you find an + # order dependency and want to debug it, you can fix the order by providing + # the seed, which is printed after each run. + # --seed 1234 + config.order = :random + + # Seed global randomization in this process using the `--seed` CLI option. + # Setting this allows you to use `--seed` to deterministically reproduce + # test failures related to randomization by passing the same `--seed` value + # as the one that triggered the failure. + Kernel.srand config.seed + + # These two settings work together to allow you to limit a spec run + # to individual examples or groups you care about by tagging them with + # `:focus` metadata. When nothing is tagged with `:focus`, all examples + # get run. + config.filter_run :focus + config.run_all_when_everything_filtered = true + + # This setting enables warnings. It's recommended, but in many cases may + # be too noisy due to issues in dependencies. + # config.warnings = false + + # Print the 10 slowest examples and example groups at the + # end of the spec run, to help surface which specs are running + # particularly slow. + # config.profile_examples = 10 + + config.before(:suite) do + DatabaseCleaner.strategy = :transaction + DatabaseCleaner.clean_with(:truncation) + end + + config.around do |example| + DatabaseCleaner.cleaning { example.run } + end + + config.include FactoryBot::Syntax::Methods + + config.before(:suite) do + FactoryBot.find_definitions + end +end + +require 'inferno/config/application' +require 'inferno/utils/migration' +Inferno::Utils::Migration.new.run + +require 'inferno' +Inferno::Application.finalize! + +require Inferno::SpecSupport::FACTORY_BOT_SUPPORT_PATH + +FactoryBot.definition_file_paths = [ + Inferno::SpecSupport::FACTORY_PATH +] + +RSpec::Matchers.define_negated_matcher :exclude, :include + +FHIR.logger = Inferno::Application['logger'] + +DatabaseCleaner[:sequel].strategy = :truncation +DatabaseCleaner[:sequel].db = Inferno::Application['db.connection'] diff --git a/worker.rb b/worker.rb new file mode 100644 index 0000000..d0183c3 --- /dev/null +++ b/worker.rb @@ -0,0 +1,3 @@ +require 'inferno' + +Inferno::Application.finalize!

Generated Narrative: Appointment

Resource Appointment "example"

status: proposed

serviceCategory: General Practice (Service category#17)

serviceType: General Practice (Service type#pat015)

specialty: General practice (specialty) (SNOMED CT#394814009)

appointmentType: A follow up visit from a previous appointment (appointmentReason#FOLLOWUP)

reasonReference: Condition/cond015a: Heart problem

priority: 5

description: Discussion on the results of your recent MRI

start: Dec 10, 2013, 9:00:00 AM

end: Dec 10, 2013, 11:00:00 AM

created: 2013-10-10

comment: Further expand on the results of the MRI and determine the next actions that may be appropriate.

basedOn: ServiceRequest/servreq-g0180-1

participant

actor: Patient/pat015: Amy Baxter " SHAW"

required: required

status: accepted

participant

type: attender (ParticipationType#ATND)

actor: Practitioner/pra1255: Dr Adam Careful " CAREFUL"

required: required

status: accepted

participant

actor: Location/example: South Wing, second floor "South Wing, second floor"

required: required

status: accepted

requestedPeriod: 2020-11-01 --> 2020-12-15