davinci_pas_test_kit 0.11.1 → 0.12.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (62) hide show
  1. checksums.yaml +4 -4
  2. data/config/presets/pas_client_example.json +30 -0
  3. data/config/presets/pas_ri.json +69 -0
  4. data/lib/davinci_pas_test_kit/client_suite.rb +28 -2
  5. data/lib/davinci_pas_test_kit/custom_groups/v2.0.1/client_tests/pas_client_approval_submit_test.rb +29 -1
  6. data/lib/davinci_pas_test_kit/custom_groups/v2.0.1/client_tests/pas_client_denial_submit_test.rb +27 -4
  7. data/lib/davinci_pas_test_kit/{generated/v2.0.1/client_tests/client_pended_pas_inquiry_request_bundle_validation_test.rb → custom_groups/v2.0.1/client_tests/pas_client_inquire_request_bundle_validation_test.rb} +22 -20
  8. data/lib/davinci_pas_test_kit/{generated/v2.0.1/client_tests/client_denial_pas_response_bundle_validation_test.rb → custom_groups/v2.0.1/client_tests/pas_client_inquire_response_bundle_validation_test.rb} +34 -21
  9. data/lib/davinci_pas_test_kit/custom_groups/v2.0.1/client_tests/pas_client_pended_submit_test.rb +124 -5
  10. data/lib/davinci_pas_test_kit/{generated/v2.0.1/client_tests/client_pas_request_bundle_validation_test.rb → custom_groups/v2.0.1/client_tests/pas_client_request_bundle_validation_test.rb} +22 -20
  11. data/lib/davinci_pas_test_kit/custom_groups/v2.0.1/client_tests/{pas_client_approval_submit_response_attest.rb → pas_client_response_attest.rb} +26 -9
  12. data/lib/davinci_pas_test_kit/{generated/v2.0.1/client_tests/client_pended_pas_response_bundle_validation_test.rb → custom_groups/v2.0.1/client_tests/pas_client_response_bundle_validation_test.rb} +44 -20
  13. data/lib/davinci_pas_test_kit/custom_groups/v2.0.1/client_tests/pas_client_subscription_create_test.rb +49 -0
  14. data/lib/davinci_pas_test_kit/custom_groups/v2.0.1/client_tests/pas_client_subscription_pas_conformance_test.rb +48 -0
  15. data/lib/davinci_pas_test_kit/custom_groups/v2.0.1/pas_client_approval_group.rb +21 -9
  16. data/lib/davinci_pas_test_kit/custom_groups/v2.0.1/pas_client_authentication_group.rb +2 -2
  17. data/lib/davinci_pas_test_kit/custom_groups/v2.0.1/pas_client_denial_group.rb +21 -22
  18. data/lib/davinci_pas_test_kit/custom_groups/v2.0.1/pas_client_pended_group.rb +97 -31
  19. data/lib/davinci_pas_test_kit/docs/PAS Requirements Interpretation.xlsx +0 -0
  20. data/lib/davinci_pas_test_kit/docs/client_suite_description_v201.md +213 -72
  21. data/lib/davinci_pas_test_kit/endpoints/claim_endpoint.rb +85 -134
  22. data/lib/davinci_pas_test_kit/endpoints/subscription_create_endpoint.rb +96 -0
  23. data/lib/davinci_pas_test_kit/endpoints/subscription_status_endpoint.rb +90 -0
  24. data/lib/davinci_pas_test_kit/fhir_resource_navigation.rb +3 -3
  25. data/lib/davinci_pas_test_kit/generated/v2.0.1/pas_inquiry_request_bundle/metadata.yml +0 -2
  26. data/lib/davinci_pas_test_kit/generated/v2.0.1/pas_inquiry_request_bundle/server_pas_inquiry_request_bundle_validation_test.rb +3 -1
  27. data/lib/davinci_pas_test_kit/generated/v2.0.1/pas_inquiry_response_bundle/server_pas_inquiry_response_bundle_validation_test.rb +2 -1
  28. data/lib/davinci_pas_test_kit/generated/v2.0.1/pas_request_bundle/metadata.yml +0 -2
  29. data/lib/davinci_pas_test_kit/generated/v2.0.1/pas_request_bundle/server_pas_request_bundle_validation_test.rb +3 -1
  30. data/lib/davinci_pas_test_kit/generated/v2.0.1/pas_response_bundle/metadata.yml +0 -4
  31. data/lib/davinci_pas_test_kit/generated/v2.0.1/pas_response_bundle/server_pas_response_bundle_validation_test.rb +2 -1
  32. data/lib/davinci_pas_test_kit/generated/v2.0.1/pas_server_denial_use_case_group.rb +1 -1
  33. data/lib/davinci_pas_test_kit/generated/v2.0.1/pas_server_pended_use_case_group.rb +6 -5
  34. data/lib/davinci_pas_test_kit/generated/v2.0.1/server_suite.rb +5 -2
  35. data/lib/davinci_pas_test_kit/generator/group_generator.rb +9 -8
  36. data/lib/davinci_pas_test_kit/generator/group_metadata_extractor.rb +7 -3
  37. data/lib/davinci_pas_test_kit/generator/templates/group.rb.erb +129 -0
  38. data/lib/davinci_pas_test_kit/generator/templates/must_support.rb.erb +73 -0
  39. data/lib/davinci_pas_test_kit/generator/templates/operation.rb.erb +62 -0
  40. data/lib/davinci_pas_test_kit/generator/templates/resource_list.rb.erb +13 -0
  41. data/lib/davinci_pas_test_kit/generator/templates/suite.rb.erb +92 -0
  42. data/lib/davinci_pas_test_kit/generator/templates/validation.rb.erb +98 -0
  43. data/lib/davinci_pas_test_kit/generator/validation_test_generator.rb +19 -56
  44. data/lib/davinci_pas_test_kit/generator/value_extractor.rb +4 -1
  45. data/lib/davinci_pas_test_kit/generator.rb +1 -1
  46. data/lib/davinci_pas_test_kit/igs/davinci_pas_2.0.1.tgz +0 -0
  47. data/lib/davinci_pas_test_kit/jobs/send_pas_subscription_notification.rb +136 -0
  48. data/lib/davinci_pas_test_kit/jobs/send_subscription_handshake.rb +139 -0
  49. data/lib/davinci_pas_test_kit/metadata.rb +87 -0
  50. data/lib/davinci_pas_test_kit/pas_bundle_validation.rb +8 -7
  51. data/lib/davinci_pas_test_kit/response_generator.rb +397 -0
  52. data/lib/davinci_pas_test_kit/tags.rb +9 -0
  53. data/lib/davinci_pas_test_kit/urls.rb +8 -0
  54. data/lib/davinci_pas_test_kit/user_input_response.rb +11 -8
  55. data/lib/davinci_pas_test_kit/validation_test.rb +0 -1
  56. data/lib/davinci_pas_test_kit/version.rb +2 -1
  57. data/lib/davinci_pas_test_kit.rb +1 -0
  58. metadata +44 -15
  59. data/lib/davinci_pas_test_kit/custom_groups/v2.0.1/client_tests/pas_client_denial_submit_response_attest.rb +0 -38
  60. data/lib/davinci_pas_test_kit/custom_groups/v2.0.1/client_tests/pas_client_pended_inquire_response_attest.rb +0 -39
  61. data/lib/davinci_pas_test_kit/custom_groups/v2.0.1/client_tests/pas_client_pended_inquire_test.rb +0 -35
  62. data/lib/davinci_pas_test_kit/custom_groups/v2.0.1/client_tests/pas_client_pended_submit_response_attest.rb +0 -39
@@ -0,0 +1,139 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative '../tags'
4
+ require 'subscriptions_test_kit'
5
+
6
+ module DaVinciPASTestKit
7
+ module Jobs
8
+ class SendSubscriptionHandshake
9
+ include Sidekiq::Job
10
+ include SubscriptionsTestKit::SubscriptionsR5BackportR4Client::SubscriptionSimulationUtils
11
+
12
+ sidekiq_options retry: false
13
+
14
+ def perform(test_run_id, test_session_id, result_id, subscription_id, subscription_url, client_endpoint,
15
+ bearer_token, notification_json, test_run_identifier, test_suite_base_url)
16
+ @test_run_id = test_run_id
17
+ @test_session_id = test_session_id
18
+ @result_id = result_id
19
+ @subscription_id = subscription_id
20
+ @subscription_url = subscription_url
21
+ @client_endpoint = client_endpoint
22
+ @bearer_token = bearer_token
23
+ @notification_json = notification_json.present? ? notification_json : default_notification_base_json
24
+ @test_run_identifier = test_run_identifier
25
+ @test_suite_base_url = test_suite_base_url
26
+
27
+ await_subscription_creation
28
+ sleep 1
29
+ return unless test_still_waiting?
30
+
31
+ send_handshake_notification
32
+ test_suite_connection.get(RESUME_PASS_PATH.delete_prefix('/'), { token: @test_run_identifier })
33
+ end
34
+
35
+ def default_notification_base_json
36
+ FHIR::Bundle.new(
37
+ timestamp: Time.now.utc.iso8601,
38
+ type: 'history',
39
+ entry: [
40
+ FHIR::Bundle::Entry.new(fullUrl: "urn:uuid:#{SecureRandom.uuid}",
41
+ resource: FHIR.from_contents(default_handshake_parameters_base_json))
42
+ ]
43
+ ).to_json
44
+ end
45
+
46
+ def default_handshake_parameters_base_json
47
+ '{ "parameter": [ { "name": "subscription", "valueReference": { "reference": "replace_with_subscription_ref" } }, { "name": "topic", "valueCanonical": "replace_with_topic_canonical" }, { "name": "status", "valueCode": "requested" }, { "name": "type", "valueCode": "handshake" }, { "name": "events-since-subscription-start", "valueString": "0" } ], "resourceType": "Parameters" }' # rubocop:disable Layout/LineLength
48
+ end
49
+
50
+ def requests_repo
51
+ @requests_repo ||= Inferno::Repositories::Requests.new
52
+ end
53
+
54
+ def results_repo
55
+ @results_repo ||= Inferno::Repositories::Results.new
56
+ end
57
+
58
+ def subscription
59
+ @subscription ||= find_subscription(@test_session_id)
60
+ end
61
+
62
+ def headers
63
+ @headers ||= subscription_headers.merge(content_type_header).merge(authorization_header)
64
+ end
65
+
66
+ def rest_hook_connection
67
+ @rest_hook_connection ||= Faraday.new(url: @client_endpoint, request: { open_timeout: 30 }, headers:)
68
+ end
69
+
70
+ def test_suite_connection
71
+ @test_suite_connection ||= Faraday.new(@test_suite_base_url)
72
+ end
73
+
74
+ def content_type_header
75
+ @content_type_header ||= { 'Content-Type' => actual_mime_type(subscription) }
76
+ end
77
+
78
+ def subscription_headers
79
+ return {} unless subscription.present?
80
+
81
+ @subscription_headers ||= subscription.channel&.header&.each_with_object({}) do |header, hash|
82
+ header_name, header_value = header.split(': ', 2)
83
+ hash[header_name] = header_value
84
+ end || {}
85
+ end
86
+
87
+ def subscription_topic
88
+ @subscription_topic ||= subscription&.criteria
89
+ end
90
+
91
+ def authorization_header
92
+ @authorization_header ||= @bearer_token.present? ? { 'Authorization' => "Bearer #{@bearer_token}" } : {}
93
+ end
94
+
95
+ def test_still_waiting?
96
+ results_repo.find_waiting_result(test_run_id: @test_run_id)
97
+ end
98
+
99
+ def await_subscription_creation
100
+ sleep 0.5 until subscription.present?
101
+ end
102
+
103
+ def send_handshake_notification
104
+ handshake_json = derive_handshake_notification(@notification_json, @subscription_url,
105
+ subscription_topic).to_json
106
+ response = send_notification(handshake_json)
107
+ persist_notification_request(response, [REST_HOOK_HANDSHAKE_NOTIFICATION_TAG])
108
+ response
109
+ end
110
+
111
+ def send_notification(request_body)
112
+ rest_hook_connection.post('', request_body)
113
+ rescue Faraday::Error => e
114
+ # Warning: This is a hack. If there is an error with the request such that we never get a response, we have
115
+ # no clean way to persist that information for the Inferno test to check later. The solution here
116
+ # is to persist the request anyway with a status of nil, using the error message as response body
117
+ Faraday::Response.new(response_body: e.message, url: rest_hook_connection.url_prefix.to_s)
118
+ end
119
+
120
+ def persist_notification_request(response, tags)
121
+ inferno_request_headers = headers.map { |name, value| { name:, value: } }
122
+ inferno_response_headers = response.headers&.map { |name, value| { name:, value: } }
123
+ requests_repo.create(
124
+ verb: 'POST',
125
+ url: response.env.url.to_s,
126
+ direction: 'outgoing',
127
+ status: response.status,
128
+ request_body: response.env.request_body,
129
+ response_body: response.env.response_body,
130
+ test_session_id: @test_session_id,
131
+ result_id: @result_id,
132
+ request_headers: inferno_request_headers,
133
+ response_headers: inferno_response_headers,
134
+ tags:
135
+ )
136
+ end
137
+ end
138
+ end
139
+ end
@@ -0,0 +1,87 @@
1
+ require_relative 'version'
2
+
3
+ module DaVinciPASTestKit
4
+ class Metadata < Inferno::TestKit
5
+ id :davinci_pas_test_kit
6
+ title 'Da Vinci Prior Authorization Support (PAS) Test Kit'
7
+ description <<~DESCRIPTION
8
+ The Da Vinci Prior Authorization Support (PAS) Test Kit validates
9
+ the conformance of both PAS client and server implementations to
10
+ [version 2.0.1 of the Da Vinci PAS Implementation
11
+ Guide](https://hl7.org/fhir/us/davinci-pas/STU2/).
12
+
13
+ <!-- break -->
14
+
15
+ To validate the behavior of a system Inferno will act as the partner to the
16
+ system under test:
17
+ - **When testing a client**: Inferno will act as a server, awaiting requests
18
+ from the client under test, returning appropriate responses, and validating
19
+ the conformance of the client's requests and its ability to handle the
20
+ responses appropriately.
21
+ - **When testing a server**: Inferno will act as a client, making requests
22
+ against the server under test and validating the conformance and
23
+ appropriateness of the server's responses.
24
+
25
+ The test suites for both PAS clients and servers follow the same basic outline,
26
+ each testing:
27
+
28
+ - The implementation's ability to use PAS-defined APIs to participate in the
29
+ submission of and decision on a prior authorization request, including:
30
+ - Approval of a prior authorization request.
31
+ - Denial of a prior authorization request.
32
+ - Pending of a prior authorization request and a subsequent final decision.
33
+ - The implementation ability to provide and handle data covering the full scope
34
+ of PAS must support requirements on:
35
+ - Prior authorization submissions.
36
+ - Prior authorization inquiries.
37
+
38
+ The Da Vinci PAS Test Kit is built using the [Inferno
39
+ Framework](https://inferno-framework.github.io/). The Inferno Framework is
40
+ designed for reuse and aims to make it easier to build test kits for any
41
+ FHIR-based data exchange.
42
+
43
+ ## Known Limitations
44
+
45
+ The following areas of the IG are not fully tested in this draft version of the test kit:
46
+
47
+ - Private X12 details including value set expansions
48
+ - The use of Subscriptions to alert clients of updates to pended requests
49
+ - Prior Authorization update workflows
50
+ - Requests for additional information handled through the CDex framework
51
+ - PDF, CDA, and JPG attachments
52
+ - US Core profile support for supporting information
53
+ - Server inquiry matching and subsetting logic
54
+ - Server inquiry requests from non-submitting systems
55
+ - Server collection of metrics
56
+ - Client handling of responses containing all PAS-defined profiles and must support elements
57
+ - Client handling of situations that requiring manual review of the client system,
58
+ e.g., the requirement that clinicians can update details of the prior authorization
59
+ request before submitting them
60
+
61
+ For additional details on these and other areas where the tests may not align with
62
+ the IGs requirements, see documentation in the test kit source code ([client](https://github.com/inferno-framework/davinci-pas-test-kit/blob/main/lib/davinci_pas_test_kit/docs/client_suite_description_v201.md#testing-limitations), [server](https://github.com/inferno-framework/davinci-pas-test-kit/blob/main/lib/davinci_pas_test_kit/docs/server_suite_description_v201.md#testing-limitations)), and [this requirements analysis
63
+ spreadsheet](https://github.com/inferno-framework/davinci-pas-test-kit/blob/main/lib/davinci_pas_test_kit/docs/PAS%20Requirements%20Interpretation.xlsx).
64
+
65
+ ### Known IG Issues
66
+
67
+ Through testing with this test kit, issues have been identified in the version of the PAS
68
+ specification that this test kit tests against which cause false failures. The full list
69
+ of known issues can be found on the [repository's issues page with the 'source ig issue'
70
+ lable](https://github.com/inferno-framework/davinci-pas-test-kit/labels/source%20ig%20issue).
71
+
72
+ ## Reporting Issues
73
+
74
+ Please report any issues with this set of tests in the [GitHub
75
+ Issues](https://github.com/inferno-framework/davinci-pas-test-kit/issues)
76
+ section of the
77
+ [open source code repository](https://github.com/inferno-framework/davinci-pas-test-kit).
78
+ DESCRIPTION
79
+ suite_ids [:davinci_pas_server_suite_v201, :davinci_pas_client_suite_v201]
80
+ tags ['Da Vinci', 'PAS']
81
+ last_updated LAST_UPDATED
82
+ version VERSION
83
+ maturity 'Low'
84
+ authors ['Inferno Team']
85
+ repo 'https://github.com/inferno-framework/davinci-pas-test-kit'
86
+ end
87
+ end
@@ -171,7 +171,7 @@ module DaVinciPASTestKit
171
171
  bundle_entry = bundle.entry
172
172
 
173
173
  root_entry = bundle_entry.find do |entry|
174
- entry.resource.resourceType == 'Claim' || entry.resource.resourceType == 'ClaimResponse'
174
+ ['Claim', 'ClaimResponse'].include?(entry.resource.resourceType)
175
175
  end
176
176
 
177
177
  if root_entry.present?
@@ -331,7 +331,9 @@ module DaVinciPASTestKit
331
331
  # @param bundle_entry [Array] The bundle.entry contents.
332
332
  # @param version [String] The IG version.
333
333
  def add_declared_profiles(instance, bundle_entry, version)
334
- instance.resource&.meta&.profile&.each do |url|
334
+ return unless instance.resource.present?
335
+
336
+ instance.resource.meta&.profile&.each do |url|
335
337
  next if bundle_resources_target_profile_map[instance.fullUrl][:profile_urls].include?(url)
336
338
 
337
339
  bundle_resources_target_profile_map[instance.fullUrl][:profile_urls] << url
@@ -462,11 +464,10 @@ module DaVinciPASTestKit
462
464
  return if ref.blank?
463
465
 
464
466
  absolute_ref = absolute_url(ref, base_url)
465
- resource_type, resource_id = ref.split('/')
466
467
  matching_resources = resources_to_match.find_all { |res| res.fullUrl == absolute_ref }
467
468
 
468
469
  if matching_resources.length != 1
469
- validation_error_messages << resource_shall_appear_once_message(resource_type, resource_id,
470
+ validation_error_messages << resource_shall_appear_once_message(absolute_ref,
470
471
  matching_resources.length)
471
472
  end
472
473
 
@@ -525,10 +526,10 @@ module DaVinciPASTestKit
525
526
  #
526
527
  # This method generates an error message when a referenced resource appears more than once
527
528
  # in a FHIR bundle, which is not allowed.
528
- def resource_shall_appear_once_message(reference_resource_type, reference_resource_id, total_matches)
529
+ def resource_shall_appear_once_message(absolute_ref, total_matches)
529
530
  "
530
- The referenced #{reference_resource_type}/#{reference_resource_id} resource
531
- SHALL only appear once in the Bundle, but found #{total_matches}.
531
+ The referenced #{absolute_ref} resource
532
+ SHALL appear exactly once in the Bundle, but found #{total_matches}.
532
533
  "
533
534
  end
534
535
 
@@ -0,0 +1,397 @@
1
+ module DaVinciPASTestKit
2
+ module ResponseGenerator
3
+ def mock_id_only_notification_bundle(submit_response, subscription_reference, subscription_topic)
4
+ notification_timestamp = Time.now.utc
5
+ mock_notification_bundle = build_mock_notification_bundle(notification_timestamp, subscription_reference,
6
+ subscription_topic, submit_response, 'id-only', nil)
7
+ mock_notification_bundle.to_json
8
+ end
9
+
10
+ def mock_full_resource_notification_bundle(submit_response, subscription_reference, subscription_topic, decision)
11
+ notification_timestamp = Time.now.utc
12
+ mock_notification_bundle = build_mock_notification_bundle(notification_timestamp, subscription_reference,
13
+ subscription_topic, submit_response, 'full-resource',
14
+ decision)
15
+ mock_notification_bundle.to_json
16
+ end
17
+
18
+ def mock_response_bundle(request_bundle, operation, decision, claim_response_uuid = nil)
19
+ mocked_timestamp = Time.now.utc
20
+ build_mock_response_bundle(request_bundle, operation, decision, mocked_timestamp, claim_response_uuid)&.to_json
21
+ end
22
+
23
+ # update things that tester cannot get right themselves
24
+ # - timestamps on the Bundle and ClaimResponse (can't predict the processing time)
25
+ # - reference to the submitted Claim (may not have control of created id). NOTE: this is likely
26
+ # incomplete - when the Claim is included, there are other things that
27
+ # need to be in the Bundle that may also not be controlled
28
+ def update_tester_provided_response(user_inputted_response, claim_full_url)
29
+ response_bundle = FHIR.from_contents(user_inputted_response)
30
+ return user_inputted_response unless response_bundle.present?
31
+
32
+ now = Time.now.utc
33
+ response_bundle.timestamp = now.iso8601 if response_bundle&.timestamp.present?
34
+ claim_response_entry = response_bundle&.entry&.find { |e| e&.resource&.resourceType == 'ClaimResponse' }
35
+ if claim_response_entry.present?
36
+ claim_response_entry.resource.created = now.iso8601 if claim_response_entry.resource.created
37
+ if claim_response_entry.resource.request.present? && claim_full_url.present?
38
+ claim_response_entry.resource.request.reference = claim_full_url
39
+ end
40
+ end
41
+ response_bundle.to_json
42
+ end
43
+
44
+ # update things that tester cannot get right themselves
45
+ # - reference to the ClaimResponse in the case that it is generated by Inferno.
46
+ # If the tester provided the ClaimResponse, they are responsible for aligning
47
+ # the notification with it.
48
+ # - notification timestamps
49
+ def update_tester_provided_notification(user_inputted_notification, generated_claim_response_uuid)
50
+ now = Time.now.utc
51
+
52
+ notification_bundle = FHIR.from_contents(user_inputted_notification)
53
+ subscription_status_entry = notification_bundle&.entry&.find { |e| e.resource&.resourceType == 'Parameters' }
54
+ subscription_status_resource = subscription_status_entry&.resource
55
+ event_parameter = subscription_status_resource&.parameter&.find { |p| p.name == 'notification-event' }
56
+
57
+ if generated_claim_response_uuid.present?
58
+ claim_response_full_url = "urn:uuid:#{generated_claim_response_uuid}"
59
+ focus_part = event_parameter&.part&.find { |pt| pt.name == 'focus' }
60
+ if focus_part.present?
61
+ existing_claim_response_reference = focus_part.valueReference&.reference
62
+ focus_part.valueReference.reference = claim_response_full_url
63
+ update_tester_provided_notification_claim_response_entry(notification_bundle,
64
+ generated_claim_response_uuid,
65
+ claim_response_full_url,
66
+ existing_claim_response_reference)
67
+
68
+ end
69
+ end
70
+
71
+ timestamp_part = event_parameter&.part&.find { |pt| pt.name == 'timestamp' }
72
+ timestamp_part.valueInstant = now.iso8601 if timestamp_part.present?
73
+ notification_bundle.timestamp = now.iso8601 if notification_bundle.timestamp.present?
74
+
75
+ notification_bundle.to_json
76
+ end
77
+
78
+ private
79
+
80
+ # if there's a ClaimResponse entry (full-resource or id-only without the resource contents)
81
+ # then update the fullUrl and id
82
+ def update_tester_provided_notification_claim_response_entry(notification_bundle, claim_response_id,
83
+ claim_response_full_url,
84
+ existing_claim_response_reference)
85
+ claim_response_entry = notification_bundle&.entry&.find { |e| e.resource&.resourceType == 'ClaimResponse' }
86
+ if claim_response_entry.blank? && existing_claim_response_reference.present?
87
+ claim_response_entry = notification_bundle&.entry&.find { |e| e.fullUrl == existing_claim_response_reference }
88
+ end
89
+ return unless claim_response_entry.present?
90
+
91
+ claim_response_entry.fullUrl = claim_response_full_url
92
+ return unless claim_response_entry.resource.present?
93
+
94
+ claim_response_entry.resource.id = claim_response_id
95
+ end
96
+
97
+ def build_mock_notification_bundle(notification_timestamp, subscription_reference, subscription_topic,
98
+ submit_response, type, decision)
99
+ submit_bundle = FHIR.from_contents(submit_response)
100
+ claim_response_full_url = claim_response_full_url_from_submit_response_bundle(submit_bundle)
101
+ mock_notification_bundle = FHIR::Bundle.new(
102
+ id: SecureRandom.uuid,
103
+ timestamp: notification_timestamp.iso8601,
104
+ type: 'history'
105
+ )
106
+
107
+ additional_context_references =
108
+ if type == 'full-resource' && submit_bundle.present? && submit_bundle.is_a?(FHIR::Bundle)
109
+ submit_bundle.entry.reject { |entry| entry.resource&.resourceType == 'ClaimResponse' }.map(&:fullUrl).compact
110
+ else
111
+ []
112
+ end
113
+ mock_notification_status = build_mock_notification_status(notification_timestamp, subscription_reference,
114
+ subscription_topic, claim_response_full_url,
115
+ additional_context_references)
116
+ mock_notification_bundle.entry << build_mock_notification_status_entry(mock_notification_status,
117
+ subscription_reference)
118
+ if type == 'full-resource' && submit_bundle.present? && submit_bundle.is_a?(FHIR::Bundle)
119
+ fhir_base_url = extract_fhir_base_url(subscription_reference)
120
+ submit_bundle.entry.each do |entry|
121
+ update_claim_response_decisions(entry.resource, decision) if entry.resource.resourceType == 'ClaimResponse'
122
+ entry.request = FHIR::Bundle::Entry::Request.new(
123
+ method: 'POST',
124
+ url: "#{fhir_base_url}/#{entry.resource.resourceType}"
125
+ )
126
+ entry.response = FHIR::Bundle::Entry::Response.new(
127
+ status: '201'
128
+ )
129
+ mock_notification_bundle.entry << entry
130
+ end
131
+ end
132
+
133
+ mock_notification_bundle
134
+ end
135
+
136
+ def update_claim_response_decisions(claim_response, decision)
137
+ claim_response&.item&.each do |item|
138
+ item.adjudication.each do |adjudication|
139
+ review_action_extension = adjudication.extension.find do |ext|
140
+ ext.url == 'http://hl7.org/fhir/us/davinci-pas/StructureDefinition/extension-reviewAction'
141
+ end
142
+ review_code_extension = review_action_extension&.extension&.find do |ext|
143
+ ext.url == 'http://hl7.org/fhir/us/davinci-pas/StructureDefinition/extension-reviewActionCode'
144
+ end
145
+ next unless review_code_extension.present?
146
+
147
+ review_code_extension.valueCodeableConcept = FHIR::CodeableConcept.new(
148
+ coding: [
149
+ get_review_action_code(decision)
150
+ ]
151
+ )
152
+ end
153
+ end
154
+ end
155
+
156
+ def build_mock_response_bundle(request_bundle, operation, decision, timestamp, claim_response_uuid = nil)
157
+ claim_entry = request_bundle&.entry&.find { |e| e&.resource&.resourceType == 'Claim' }
158
+ claim_full_url = claim_entry&.fullUrl
159
+ return nil if claim_entry.blank? || claim_full_url.blank?
160
+
161
+ root_url = extract_fhir_base_url(claim_full_url)
162
+ mocked_claim_response = build_mock_claim_response(claim_entry.resource, request_bundle, root_url, operation,
163
+ decision, timestamp, claim_response_uuid)
164
+ build_mock_bundle(mocked_claim_response, request_bundle, root_url, operation, timestamp)
165
+ end
166
+
167
+ # Note that references from the claim to other resources in the bundle need to be changed to absolute URLs
168
+ # if they are relative, because the ClaimResponse's fullUrl is a urn:uuid
169
+ #
170
+ # @private
171
+ def build_mock_claim_response(claim, request_bundle, root_url, operation, decision, timestamp,
172
+ claim_response_uuid = nil)
173
+ claim_response_uuid = SecureRandom.uuid if claim_response_uuid.blank?
174
+ return FHIR::ClaimResponse.new(id: claim_response_uuid) if claim.blank?
175
+
176
+ FHIR::ClaimResponse.new(
177
+ id: claim_response_uuid,
178
+ meta: FHIR::Meta.new(profile: if operation == 'submit'
179
+ 'http://hl7.org/fhir/us/davinci-pas/StructureDefinition/profile-claimresponse'
180
+ else
181
+ 'http://hl7.org/fhir/us/davinci-pas/StructureDefinition/profile-claiminquiryresponse'
182
+ end),
183
+ identifier: claim.identifier,
184
+ type: claim.type,
185
+ status: claim.status,
186
+ use: claim.use,
187
+ patient: absolute_reference(claim.patient, request_bundle.entry, root_url),
188
+ created: timestamp.iso8601,
189
+ insurer: absolute_reference(claim.insurer, request_bundle.entry, root_url),
190
+ requestor: absolute_reference(claim.provider, request_bundle.entry, root_url),
191
+ outcome: 'complete',
192
+ item: claim.item.map do |item|
193
+ FHIR::ClaimResponse::Item.new(
194
+ extension: [
195
+ FHIR::Extension.new(
196
+ url: 'http://hl7.org/fhir/us/davinci-pas/StructureDefinition/extension-itemPreAuthIssueDate',
197
+ valueDate: timestamp.strftime('%Y-%m-%d')
198
+ ),
199
+ FHIR::Extension.new(
200
+ url: 'http://hl7.org/fhir/us/davinci-pas/StructureDefinition/extension-itemPreAuthPeriod',
201
+ valuePeriod: FHIR::Period.new(start: timestamp.strftime('%Y-%m-%d'),
202
+ end: (timestamp + 1.month).strftime('%Y-%m-%d'))
203
+ )
204
+ ],
205
+ itemSequence: item.sequence,
206
+ adjudication: [
207
+ FHIR::ClaimResponse::Item::Adjudication.new(
208
+ extension: [
209
+ FHIR::Extension.new(
210
+ url: 'http://hl7.org/fhir/us/davinci-pas/StructureDefinition/extension-reviewAction',
211
+ extension: [
212
+ FHIR::Extension.new(
213
+ url: 'http://hl7.org/fhir/us/davinci-pas/StructureDefinition/extension-reviewActionCode',
214
+ valueCodeableConcept: FHIR::CodeableConcept.new(
215
+ coding: [
216
+ get_review_action_code(decision)
217
+ ]
218
+ )
219
+ )
220
+ ]
221
+ )
222
+ ],
223
+ category: FHIR::CodeableConcept.new(
224
+ coding: [
225
+ FHIR::Coding.new(system: 'http://terminology.hl7.org/CodeSystem/adjudication', code: 'submitted')
226
+ ]
227
+ )
228
+ )
229
+ ]
230
+ )
231
+ end
232
+ )
233
+ end
234
+
235
+ def build_mock_bundle(claim_response, request_bundle, root_url, operation, timestamp)
236
+ response_bundle = FHIR::Bundle.new(
237
+ id: SecureRandom.uuid,
238
+ meta: FHIR::Meta.new(profile: if operation == 'submit'
239
+ 'http://hl7.org/fhir/us/davinci-pas/StructureDefinition/profile-pas-response-bundle'
240
+ else
241
+ 'http://hl7.org/fhir/us/davinci-pas/StructureDefinition/profile-pas-inquiry-response-bundle'
242
+ end),
243
+ timestamp: timestamp.iso8601,
244
+ type: 'collection',
245
+ entry: [
246
+ FHIR::Bundle::Entry.new(fullUrl: "urn:uuid:#{claim_response.id}",
247
+ resource: claim_response)
248
+ ]
249
+ )
250
+ response_bundle.entry.concat(referenced_entities(claim_response, request_bundle.entry, root_url))
251
+ response_bundle
252
+ end
253
+
254
+ def build_mock_notification_status_entry(status_parameters, subscription_reference)
255
+ FHIR::Bundle::Entry.new(fullUrl: "urn:uuid:#{SecureRandom.uuid}",
256
+ resource: status_parameters,
257
+ request: FHIR::Bundle::Entry::Request.new(
258
+ method: 'GET',
259
+ url: "#{subscription_reference}/$status"
260
+ ),
261
+ response: FHIR::Bundle::Entry::Response.new(
262
+ status: '200'
263
+ ))
264
+ end
265
+
266
+ def build_mock_notification_status(timestamp, subscription_reference, subscription_topic, claim_response_reference,
267
+ additional_context_references)
268
+ status_parameters = FHIR::Parameters.new
269
+ status_parameters.parameter << FHIR::Parameters::Parameter.new(
270
+ name: 'subscription',
271
+ valueReference: FHIR::Reference.new(
272
+ reference: subscription_reference
273
+ )
274
+ )
275
+ status_parameters.parameter << FHIR::Parameters::Parameter.new(
276
+ name: 'topic',
277
+ valueCanonical: subscription_topic
278
+ )
279
+ status_parameters.parameter << FHIR::Parameters::Parameter.new(
280
+ name: 'status',
281
+ valueCode: 'active'
282
+ )
283
+ status_parameters.parameter << FHIR::Parameters::Parameter.new(
284
+ name: 'type',
285
+ valueCode: 'event-notification'
286
+ )
287
+ status_parameters.parameter << FHIR::Parameters::Parameter.new(
288
+ name: 'events-since-subscription-start',
289
+ valueString: '1'
290
+ )
291
+ if claim_response_reference.present?
292
+ event = FHIR::Parameters::Parameter.new(
293
+ name: 'notification-event'
294
+ )
295
+ status_parameters.parameter << event
296
+ event.part << FHIR::Parameters::Parameter.new(
297
+ name: 'event-number',
298
+ valueString: '1'
299
+ )
300
+ event.part << FHIR::Parameters::Parameter.new(
301
+ name: 'timestamp',
302
+ valueInstant: timestamp.iso8601
303
+ )
304
+ event.part << FHIR::Parameters::Parameter.new(
305
+ name: 'focus',
306
+ valueReference: FHIR::Reference.new(
307
+ reference: claim_response_reference
308
+ )
309
+ )
310
+ additional_context_references&.each do |reference|
311
+ event.part << FHIR::Parameters::Parameter.new(
312
+ name: 'additional-context',
313
+ valueReference: FHIR::Reference.new(
314
+ reference:
315
+ )
316
+ )
317
+ end
318
+ end
319
+ status_parameters
320
+ end
321
+
322
+ def get_review_action_code(decision)
323
+ case decision
324
+ when :denial
325
+ code = 'A3'
326
+ display = 'Not Certified'
327
+ when :pended
328
+ code = 'A4'
329
+ display = 'Pending'
330
+ else # approval or no workflow
331
+ code = 'A1'
332
+ display = 'Certified in total'
333
+ end
334
+ FHIR::Coding.new(
335
+ system: 'https://codesystem.x12.org/005010/306',
336
+ code:,
337
+ display:
338
+ )
339
+ end
340
+
341
+ def absolute_reference(ref, entries, root_url)
342
+ url = find_matching_entry(ref&.reference, entries, root_url)&.fullUrl
343
+ ref.reference = url if url
344
+ ref
345
+ end
346
+
347
+ def referenced_entities(resource, entries, root_url)
348
+ matches = []
349
+ attributes = resource&.source_hash&.keys
350
+ attributes.each do |attr|
351
+ value = resource.send(attr.to_sym)
352
+ if value.is_a?(FHIR::Reference) && value.reference.present?
353
+ match = find_matching_entry(value.reference, entries, root_url)
354
+ if match.present? && matches.none?(match)
355
+ value.reference = match.fullUrl
356
+ matches.concat([match], referenced_entities(match.resource, entries, root_url))
357
+ end
358
+ elsif value.is_a?(Array) && value.all? { |elmt| elmt.is_a?(FHIR::Model) }
359
+ value.each { |val| matches.concat(referenced_entities(val, entries, root_url)) }
360
+ end
361
+ end
362
+
363
+ matches
364
+ end
365
+
366
+ def find_matching_entry(ref, entries, root_url = '')
367
+ ref = "#{root_url}/#{ref}" if relative_reference?(ref) && root_url&.present?
368
+
369
+ entries&.find { |entry| entry&.fullUrl == ref }
370
+ end
371
+
372
+ def relative_reference?(ref)
373
+ ref&.count('/') == 1
374
+ end
375
+
376
+ # Drop the last two segments of a URL, i.e. the resource type and ID of a FHIR resource
377
+ # e.g. http://example.org/fhir/Patient/123 -> http://example.org/fhir
378
+ def extract_fhir_base_url(url)
379
+ return unless url&.start_with?('http://', 'https://')
380
+
381
+ # Drop everything after the second to last '/', ignoring a trailing slash
382
+ url.sub(%r{/[^/]*/[^/]*(/)?\z}, '')
383
+ end
384
+
385
+ def claim_response_full_url_from_submit_response_bundle(submit_bundle)
386
+ claim_response_entry = submit_bundle&.entry&.find { |e| e&.resource&.resourceType == 'ClaimResponse' }
387
+
388
+ if claim_response_entry.present?
389
+ return claim_response_entry.fullUrl unless claim_response_entry.fullUrl.blank?
390
+ unless claim_response_entry.resource.blank? || claim_response_entry.resource.id.blank?
391
+ return claim_response_entry.resource.id
392
+ end
393
+ end
394
+ "urn:uuid:#{SecureRandom.uuid}"
395
+ end
396
+ end
397
+ end