davinci_pas_test_kit 0.15.0 → 0.15.2

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 (98) hide show
  1. checksums.yaml +4 -4
  2. data/config/presets/pas_client_example.json +2 -2
  3. data/config/presets/pas_client_v201_run_against_pas_server.json +2 -2
  4. data/config/presets/pas_client_v221_run_against_pas_server.json +3 -3
  5. data/config/presets/pas_ri.json +6 -6
  6. data/config/presets/pas_server_v201_run_against_pas_client.json.erb +6 -6
  7. data/config/presets/pas_server_v221_run_against_pas_client.json.erb +10 -10
  8. data/lib/davinci_pas_test_kit/client/abstract_gather_must_support_test.rb +2 -48
  9. data/lib/davinci_pas_test_kit/client/client_input_descriptions.rb +29 -0
  10. data/lib/davinci_pas_test_kit/client/endpoints/claim_endpoint.rb +25 -9
  11. data/lib/davinci_pas_test_kit/client/user_input_response.rb +25 -6
  12. data/lib/davinci_pas_test_kit/client/v2.0.1/client_suite.rb +1 -1
  13. data/lib/davinci_pas_test_kit/client/v2.2.1/client_suite.rb +1 -1
  14. data/lib/davinci_pas_test_kit/cross_suite/fhirpath_utils.rb +13 -3
  15. data/lib/davinci_pas_test_kit/cross_suite/generated/v2.0.1/beneficiary/metadata.yml +4 -0
  16. data/lib/davinci_pas_test_kit/cross_suite/generated/v2.0.1/claim_inquiry/metadata.yml +15 -0
  17. data/lib/davinci_pas_test_kit/cross_suite/generated/v2.0.1/claim_update/metadata.yml +17 -0
  18. data/lib/davinci_pas_test_kit/cross_suite/generated/v2.0.1/claiminquiryresponse/metadata.yml +15 -0
  19. data/lib/davinci_pas_test_kit/cross_suite/generated/v2.0.1/claimresponse/metadata.yml +15 -0
  20. data/lib/davinci_pas_test_kit/cross_suite/generated/v2.0.1/communication_request/metadata.yml +4 -0
  21. data/lib/davinci_pas_test_kit/cross_suite/generated/v2.0.1/coverage/metadata.yml +1 -0
  22. data/lib/davinci_pas_test_kit/cross_suite/generated/v2.0.1/device_request/metadata.yml +2 -0
  23. data/lib/davinci_pas_test_kit/cross_suite/generated/v2.0.1/encounter/metadata.yml +3 -0
  24. data/lib/davinci_pas_test_kit/cross_suite/generated/v2.0.1/insurer/metadata.yml +1 -0
  25. data/lib/davinci_pas_test_kit/cross_suite/generated/v2.0.1/medication_request/metadata.yml +2 -0
  26. data/lib/davinci_pas_test_kit/cross_suite/generated/v2.0.1/metadata.yml +143 -0
  27. data/lib/davinci_pas_test_kit/cross_suite/generated/v2.0.1/nutrition_order/metadata.yml +2 -0
  28. data/lib/davinci_pas_test_kit/cross_suite/generated/v2.0.1/pas_inquiry_request_bundle/metadata.yml +2 -0
  29. data/lib/davinci_pas_test_kit/cross_suite/generated/v2.0.1/pas_inquiry_response_bundle/metadata.yml +2 -0
  30. data/lib/davinci_pas_test_kit/cross_suite/generated/v2.0.1/pas_request_bundle/metadata.yml +2 -0
  31. data/lib/davinci_pas_test_kit/cross_suite/generated/v2.0.1/pas_response_bundle/metadata.yml +2 -0
  32. data/lib/davinci_pas_test_kit/cross_suite/generated/v2.0.1/practitioner/metadata.yml +1 -0
  33. data/lib/davinci_pas_test_kit/cross_suite/generated/v2.0.1/practitioner_role/metadata.yml +1 -0
  34. data/lib/davinci_pas_test_kit/cross_suite/generated/v2.0.1/requestor/metadata.yml +1 -0
  35. data/lib/davinci_pas_test_kit/cross_suite/generated/v2.0.1/service_request/metadata.yml +3 -0
  36. data/lib/davinci_pas_test_kit/cross_suite/generated/v2.0.1/subscriber/metadata.yml +4 -0
  37. data/lib/davinci_pas_test_kit/cross_suite/generated/v2.0.1/task/metadata.yml +1 -0
  38. data/lib/davinci_pas_test_kit/cross_suite/generated/v2.2.1/beneficiary/metadata.yml +1 -0
  39. data/lib/davinci_pas_test_kit/cross_suite/generated/v2.2.1/claim_inquiry/metadata.yml +23 -0
  40. data/lib/davinci_pas_test_kit/cross_suite/generated/v2.2.1/claim_update/metadata.yml +31 -0
  41. data/lib/davinci_pas_test_kit/cross_suite/generated/v2.2.1/claiminquiryresponse/metadata.yml +42 -0
  42. data/lib/davinci_pas_test_kit/cross_suite/generated/v2.2.1/claimresponse/metadata.yml +42 -0
  43. data/lib/davinci_pas_test_kit/cross_suite/generated/v2.2.1/communication_request/metadata.yml +4 -0
  44. data/lib/davinci_pas_test_kit/cross_suite/generated/v2.2.1/coverage/metadata.yml +1 -0
  45. data/lib/davinci_pas_test_kit/cross_suite/generated/v2.2.1/device_request/metadata.yml +2 -0
  46. data/lib/davinci_pas_test_kit/cross_suite/generated/v2.2.1/encounter/metadata.yml +3 -0
  47. data/lib/davinci_pas_test_kit/cross_suite/generated/v2.2.1/insurer/metadata.yml +1 -0
  48. data/lib/davinci_pas_test_kit/cross_suite/generated/v2.2.1/medication_request/metadata.yml +2 -0
  49. data/lib/davinci_pas_test_kit/cross_suite/generated/v2.2.1/metadata.yml +265 -0
  50. data/lib/davinci_pas_test_kit/cross_suite/generated/v2.2.1/nutrition_order/metadata.yml +2 -0
  51. data/lib/davinci_pas_test_kit/cross_suite/generated/v2.2.1/pas_inquiry_request_bundle/metadata.yml +2 -0
  52. data/lib/davinci_pas_test_kit/cross_suite/generated/v2.2.1/pas_inquiry_response_bundle/metadata.yml +2 -0
  53. data/lib/davinci_pas_test_kit/cross_suite/generated/v2.2.1/pas_request_bundle/metadata.yml +2 -0
  54. data/lib/davinci_pas_test_kit/cross_suite/generated/v2.2.1/pas_response_bundle/metadata.yml +2 -0
  55. data/lib/davinci_pas_test_kit/cross_suite/generated/v2.2.1/practitioner/metadata.yml +1 -0
  56. data/lib/davinci_pas_test_kit/cross_suite/generated/v2.2.1/practitioner_role/metadata.yml +1 -0
  57. data/lib/davinci_pas_test_kit/cross_suite/generated/v2.2.1/requestor/metadata.yml +1 -0
  58. data/lib/davinci_pas_test_kit/cross_suite/generated/v2.2.1/service_request/metadata.yml +3 -0
  59. data/lib/davinci_pas_test_kit/cross_suite/generated/v2.2.1/subscriber/metadata.yml +1 -0
  60. data/lib/davinci_pas_test_kit/cross_suite/generated/v2.2.1/task/metadata.yml +4 -0
  61. data/lib/davinci_pas_test_kit/cross_suite/pas_bundle_validation.rb +97 -39
  62. data/lib/davinci_pas_test_kit/cross_suite/pas_constants.rb +6 -0
  63. data/lib/davinci_pas_test_kit/cross_suite/pas_datatype_constraints.rb +166 -0
  64. data/lib/davinci_pas_test_kit/cross_suite/response_selection_utils.rb +26 -10
  65. data/lib/davinci_pas_test_kit/cross_suite/validator_suppressions.rb +206 -146
  66. data/lib/davinci_pas_test_kit/docs/demo/PAS Client Suite Demonstration.postman_collection.json +4 -4
  67. data/lib/davinci_pas_test_kit/generator/must_support_test_generator.rb +6 -0
  68. data/lib/davinci_pas_test_kit/generator/pas_must_support_metadata_extractor.rb +1 -2
  69. data/lib/davinci_pas_test_kit/generator/server_must_support_group_generator.rb +23 -0
  70. data/lib/davinci_pas_test_kit/generator/server_suite_generator.rb +6 -0
  71. data/lib/davinci_pas_test_kit/generator/templates/must_support.rb.erb +3 -2
  72. data/lib/davinci_pas_test_kit/generator/templates/server_must_support_group.rb.erb +22 -0
  73. data/lib/davinci_pas_test_kit/generator/templates/server_suite.rb.erb +4 -5
  74. data/lib/davinci_pas_test_kit/generator/templates/use_case_group.rb.erb +3 -0
  75. data/lib/davinci_pas_test_kit/generator/use_case_group_generator.rb +14 -8
  76. data/lib/davinci_pas_test_kit/requirements/generated/davinci_pas_server_suite_v221_requirements_coverage.csv +28 -28
  77. data/lib/davinci_pas_test_kit/server/generated/v2.0.1/pas_server_approval_use_case_group.rb +1 -0
  78. data/lib/davinci_pas_test_kit/server/generated/v2.0.1/pas_server_denial_use_case_group.rb +1 -0
  79. data/lib/davinci_pas_test_kit/server/generated/v2.0.1/pas_server_pended_use_case_group.rb +1 -0
  80. data/lib/davinci_pas_test_kit/server/generated/v2.0.1/server_suite.rb +4 -5
  81. data/lib/davinci_pas_test_kit/server/generated/v2.2.1/claiminquiryresponse/server_inquire_response_must_support_claiminquiryresponse_test.rb +2 -0
  82. data/lib/davinci_pas_test_kit/server/generated/v2.2.1/claimresponse/server_submit_response_must_support_claimresponse_test.rb +2 -0
  83. data/lib/davinci_pas_test_kit/server/generated/v2.2.1/pas_server_approval_use_case_group.rb +2 -0
  84. data/lib/davinci_pas_test_kit/server/generated/v2.2.1/pas_server_denial_use_case_group.rb +2 -0
  85. data/lib/davinci_pas_test_kit/server/generated/v2.2.1/pas_server_must_support_group.rb +12 -0
  86. data/lib/davinci_pas_test_kit/server/generated/v2.2.1/pas_server_pended_use_case_group.rb +5 -0
  87. data/lib/davinci_pas_test_kit/server/generated/v2.2.1/server_suite.rb +4 -5
  88. data/lib/davinci_pas_test_kit/server/server_response_bundle_validation_test.rb +14 -5
  89. data/lib/davinci_pas_test_kit/server/v2.2.1/claim_inquiry/server_claim_inquiry_response_tests.rb +89 -0
  90. data/lib/davinci_pas_test_kit/server/v2.2.1/pas_error_group.rb +2 -0
  91. data/lib/davinci_pas_test_kit/server/v2.2.1/pas_server_claim_inquiry_responses_group.rb +17 -0
  92. data/lib/davinci_pas_test_kit/server/v2.2.1/pas_server_no_custom_extensions_test.rb +241 -0
  93. data/lib/davinci_pas_test_kit/server/v2.2.1/pas_server_subscription_capability_statement_test.rb +40 -0
  94. data/lib/davinci_pas_test_kit/server/v2.2.1/pas_server_subscription_setup.rb +14 -9
  95. data/lib/davinci_pas_test_kit/server/v2.2.1/server_replaced_pa_inquire_reference_input_test.rb +63 -0
  96. data/lib/davinci_pas_test_kit/server/v2.2.1/server_replaced_pa_inquire_reference_test.rb +109 -0
  97. data/lib/davinci_pas_test_kit/version.rb +2 -2
  98. metadata +9 -2
@@ -18,6 +18,7 @@
18
18
  :must_supports:
19
19
  :extensions:
20
20
  - :id: NutritionOrder.extension:coverage
21
+ :slice_name: coverage
21
22
  :path: extension
22
23
  :url: http://hl7.org/fhir/us/davinci-crd/StructureDefinition/ext-coverage-information
23
24
  :modifier_extension: false
@@ -34,6 +35,7 @@
34
35
  - :path: oralDiet.type
35
36
  - :path: enteralFormula
36
37
  - :path: enteralFormula.baseFormulaType
38
+ :recursive_elements: []
37
39
  :mandatory_elements:
38
40
  - NutritionOrder.status
39
41
  - NutritionOrder.intent
@@ -31,6 +31,8 @@
31
31
  - :path: entry.resource
32
32
  - :path: entry:Claim.fullUrl
33
33
  - :path: entry:Claim.resource
34
+ :recursive_elements:
35
+ - link
34
36
  :mandatory_elements:
35
37
  - Bundle.identifier
36
38
  - Bundle.type
@@ -30,6 +30,8 @@
30
30
  - :path: entry.resource
31
31
  - :path: entry:ClaimResponse.fullUrl
32
32
  - :path: entry:ClaimResponse.resource
33
+ :recursive_elements:
34
+ - link
33
35
  :mandatory_elements:
34
36
  - Bundle.type
35
37
  - Bundle.timestamp
@@ -31,6 +31,8 @@
31
31
  - :path: entry.resource
32
32
  - :path: entry:Claim.fullUrl
33
33
  - :path: entry:Claim.resource
34
+ :recursive_elements:
35
+ - link
34
36
  :mandatory_elements:
35
37
  - Bundle.identifier
36
38
  - Bundle.type
@@ -31,6 +31,8 @@
31
31
  - :path: entry.resource
32
32
  - :path: entry:ClaimResponse.fullUrl
33
33
  - :path: entry:ClaimResponse.resource
34
+ :recursive_elements:
35
+ - link
34
36
  :mandatory_elements:
35
37
  - Bundle.identifier
36
38
  - Bundle.type
@@ -36,6 +36,7 @@
36
36
  - :path: address.state
37
37
  - :path: address.postalCode
38
38
  - :path: address.country
39
+ :recursive_elements: []
39
40
  :mandatory_elements:
40
41
  - Practitioner.identifier
41
42
  - Practitioner.name
@@ -40,6 +40,7 @@
40
40
  - :path: endpoint
41
41
  :types:
42
42
  - Reference
43
+ :recursive_elements: []
43
44
  :mandatory_elements:
44
45
  - PractitionerRole.practitioner
45
46
  - PractitionerRole.telecom.system
@@ -56,6 +56,7 @@
56
56
  - :path: contact
57
57
  - :path: contact.name
58
58
  - :path: contact.telecom
59
+ :recursive_elements: []
59
60
  :mandatory_elements:
60
61
  - Organization.active
61
62
  - Organization.name
@@ -17,10 +17,12 @@
17
17
  :must_supports:
18
18
  :extensions:
19
19
  - :id: ServiceRequest.extension:serviceCodeEnd
20
+ :slice_name: serviceCodeEnd
20
21
  :path: extension
21
22
  :url: http://hl7.org/fhir/us/davinci-pas/StructureDefinition/extension-productOrServiceCodeEnd
22
23
  :modifier_extension: false
23
24
  - :id: ServiceRequest.extension:coverage
25
+ :slice_name: coverage
24
26
  :path: extension
25
27
  :url: http://hl7.org/fhir/us/davinci-crd/StructureDefinition/ext-coverage-information
26
28
  :modifier_extension: false
@@ -36,6 +38,7 @@
36
38
  :target_profiles:
37
39
  - http://hl7.org/fhir/us/davinci-pas/StructureDefinition/profile-beneficiary|2.2.1
38
40
  - :path: occurrence[x]
41
+ :recursive_elements: []
39
42
  :mandatory_elements:
40
43
  - ServiceRequest.status
41
44
  - ServiceRequest.intent
@@ -38,6 +38,7 @@
38
38
  - :path: address.postalCode
39
39
  - :path: address.country
40
40
  - :path: communication.language
41
+ :recursive_elements: []
41
42
  :mandatory_elements:
42
43
  - Patient.identifier
43
44
  - Patient.identifier.system
@@ -18,14 +18,17 @@
18
18
  :must_supports:
19
19
  :extensions:
20
20
  - :id: Task.input:AttachmentsNeeded.extension:paLineNumber
21
+ :slice_name: paLineNumber
21
22
  :path: input.extension
22
23
  :url: http://hl7.org/fhir/us/davinci-pas/StructureDefinition/extension-serviceLineNumber
23
24
  :modifier_extension: false
24
25
  - :id: Task.input:AttachmentsNeeded.extension:contentModifier
26
+ :slice_name: contentModifier
25
27
  :path: input.extension
26
28
  :url: http://hl7.org/fhir/us/davinci-pas/StructureDefinition/extension-contentModifier
27
29
  :modifier_extension: false
28
30
  - :id: Task.input:QuestionnaireContext.extension:paLineNumber
31
+ :slice_name: paLineNumber
29
32
  :path: input.extension
30
33
  :url: http://hl7.org/fhir/us/davinci-pas/StructureDefinition/extension-serviceLineNumber
31
34
  :modifier_extension: false
@@ -86,6 +89,7 @@
86
89
  - :path: input:AttachmentsNeeded.value[x]
87
90
  - :path: input:QuestionnaireContext.type
88
91
  - :path: input:QuestionnaireContext.value[x]
92
+ :recursive_elements: []
89
93
  :mandatory_elements:
90
94
  - Task.identifier
91
95
  - Task.status
@@ -3,10 +3,12 @@
3
3
  require_relative '../parameters_helper'
4
4
  require_relative 'validation_test'
5
5
  require_relative 'pas_constants'
6
+ require_relative 'pas_datatype_constraints'
6
7
 
7
8
  module DaVinciPASTestKit
8
9
  module PasBundleValidation
9
10
  include DaVinciPASTestKit::ValidationTest
11
+ include DaVinciPASTestKit::PasDatatypeConstraints
10
12
  include ParametersHelper
11
13
 
12
14
  US_CORE_VERSION = '6.1.0'
@@ -80,13 +82,13 @@ module DaVinciPASTestKit
80
82
  @validation_error_messages ||= []
81
83
  end
82
84
 
83
- def perform_bundle_validation(bundle, operation, type, ig_version)
85
+ def perform_bundle_validation(bundle, operation, type, ig_version, request_bundle = nil)
84
86
  target_profile = PASConstants.bundle_profile_url_for_operation_and_type(operation, type)
85
87
  request_type = "#{operation}_#{type}"
86
88
  if type == 'request'
87
89
  perform_request_validation(bundle, target_profile, ig_version.delete_prefix('v'), request_type)
88
90
  else
89
- perform_response_validation(bundle, target_profile, ig_version.delete_prefix('v'), request_type)
91
+ perform_response_validation(bundle, target_profile, ig_version.delete_prefix('v'), request_type, request_bundle)
90
92
  end
91
93
  end
92
94
 
@@ -96,7 +98,7 @@ module DaVinciPASTestKit
96
98
  end
97
99
 
98
100
  def perform_response_validation(response_bundle, profile_url, version, request_type, request_bundle = nil)
99
- validate_pa_response_body_structure(response_bundle, request_bundle) if request_type == 'submit'
101
+ validate_pa_response_body_structure(response_bundle, request_bundle) if request_type.start_with?('submit')
100
102
  validate_resources_conformance_against_profile(response_bundle, profile_url, version, request_type)
101
103
  end
102
104
 
@@ -203,7 +205,7 @@ module DaVinciPASTestKit
203
205
  # Validates the response body structure of a Prior Authorization (PA) response.
204
206
  #
205
207
  # @param pa_response_bundle [FHIR::Bundle] The FHIR bundle representing the PA response.
206
- # @param pa_request_bundle [String] The JSON payload of the PA request bundle.
208
+ # @param pa_request_bundle [FHIR::Bundle, String, nil] The submitted PA request Bundle, either parsed or JSON.
207
209
  #
208
210
  # This method performs validation of the PA response bundle structure.
209
211
  # It follows the PAS IG requirement that the FHIR Bundle generated
@@ -214,7 +216,7 @@ module DaVinciPASTestKit
214
216
  # a given combination of content. Resources echoed back from the request are validated
215
217
  # to ensure the same fullUrl and resource identifiers as in the
216
218
  # request are used.
217
- def validate_pa_response_body_structure(pa_response_bundle, _pa_request_bundle)
219
+ def validate_pa_response_body_structure(pa_response_bundle, pa_request_bundle)
218
220
  first_entry = pa_response_bundle.entry.first.resource
219
221
  unless first_entry.is_a?(FHIR::ClaimResponse)
220
222
  validation_error_messages <<
@@ -224,22 +226,52 @@ module DaVinciPASTestKit
224
226
  base_url = extract_base_url(pa_response_bundle.entry.last&.fullUrl)
225
227
  check_presence_of_referenced_resources(first_entry, base_url, pa_response_bundle.entry)
226
228
 
227
- # Testing: When echoing back resources that are the same as were present in the prior authorization request,
228
- # the system SHALL ensure that the same fullUrl and resource identifiers are used in the response as appeared
229
- # in the request
230
- # pa_request_bundle = FHIR.from_contents(pa_request_bundle)
231
- # pa_response_bundle.entry.each do |entry|
232
- # res = entry.resource
233
- # request_entry = pa_request_bundle.entry.find do |ent|
234
- # ent.resource.resourceType == res.resourceType && ent.resource.id == res.id
235
- # end
236
- # next unless request_entry.present?
229
+ validate_echoed_response_resources(pa_response_bundle, pa_request_bundle)
230
+ end
231
+
232
+ def validate_echoed_response_resources(pa_response_bundle, pa_request_bundle)
233
+ return if pa_request_bundle.blank?
234
+
235
+ request_bundle = pa_request_bundle.is_a?(FHIR::Bundle) ? pa_request_bundle : FHIR.from_contents(pa_request_bundle)
236
+ return unless request_bundle.is_a?(FHIR::Bundle)
237
+
238
+ pa_response_bundle.entry.each do |response_entry|
239
+ response_resource = response_entry.resource
240
+ next if response_resource.blank?
241
+
242
+ request_entry = request_bundle.entry.find do |entry|
243
+ echoed_resource?(entry, response_entry)
244
+ end
245
+ next if request_entry.blank?
246
+
247
+ next if echoed_resource_identifiers_match?(request_entry, response_entry)
237
248
 
238
- # assert(
239
- # request_entry.fullUrl == entry.fullUrl && request_entry.resource.identifier == res.identifier,
240
- # resource_present_in_pa_request_and_response_msg(res)
241
- # )
242
- # end
249
+ validation_error_messages << resource_present_in_pa_request_and_response_msg(response_resource)
250
+ end
251
+ rescue StandardError
252
+ validation_error_messages << 'Unable to compare PAS request and response Bundle resources for echoed identifiers.'
253
+ end
254
+
255
+ def echoed_resource?(request_entry, response_entry)
256
+ request_resource = request_entry.resource
257
+ response_resource = response_entry.resource
258
+ return false if request_resource.blank? || response_resource.blank?
259
+ return false unless request_resource.resourceType == response_resource.resourceType
260
+
261
+ # True if any identifying field is present on both sides and matches, so we can later report if any
262
+ # do not have all matching identifiers. Blank fields do not identify echoed resources.
263
+ (request_entry.fullUrl.present? && request_entry.fullUrl == response_entry.fullUrl) ||
264
+ (request_resource.id.present? && request_resource.id == response_resource.id) ||
265
+ (request_resource.identifier.present? && request_resource.identifier == response_resource.identifier)
266
+ end
267
+
268
+ def echoed_resource_identifiers_match?(request_entry, response_entry)
269
+ request_resource = request_entry.resource
270
+ response_resource = response_entry.resource
271
+
272
+ request_entry.fullUrl == response_entry.fullUrl &&
273
+ request_resource.id == response_resource.id &&
274
+ request_resource.identifier == response_resource.identifier
243
275
  end
244
276
 
245
277
  # Profile conformance of Prior Authorization (PA) resources.
@@ -299,27 +331,36 @@ module DaVinciPASTestKit
299
331
  end
300
332
 
301
333
  # Validates bundle resource and each entry in the bundle against its target profiles.
302
- # It logs a message for each conformant entry
303
- # and collects error messages for non-conformant entries. Asserts that there are no validation errors.
334
+ # Validation messages are collected per profile rather than logged directly, so a
335
+ # resource with multiple candidate profiles only reports errors when it fails all of
336
+ # them. When a profile passes, only that profile's (non-error) messages are logged.
304
337
  # @param version [String] The version of the IG.
305
338
  def validate_bundle_entries_against_profiles(version)
306
339
  bundle_resources_target_profile_map.each do |key, item|
307
340
  resource = item[:resource]
308
341
  base_profile = FHIR::Definitions.resource_definition(resource.resourceType).url
342
+ messages_by_profile = {}
309
343
 
310
344
  success_profile = item[:profile_urls].find do |url|
311
- if key == 'bundle'
312
- profile_to_validate = profile_url_for_validation(url, base_profile, version)
313
- bundle_profile_valid?(resource, profile_to_validate)
314
- elsif url == BASE_R4_PROFILE
315
- resource_is_valid?(resource:)
316
- else
317
- profile_to_validate = profile_url_for_validation(url, base_profile, version)
318
- resource_is_valid?(resource:, profile_url: profile_to_validate)
319
- end
345
+ profile_messages =
346
+ if key == 'bundle'
347
+ collect_bundle_profile_messages(resource, profile_url_for_validation(url, base_profile, version))
348
+ elsif url == BASE_R4_PROFILE
349
+ collect_resource_profile_messages(resource)
350
+ else
351
+ collect_resource_profile_messages(resource, profile_url_for_validation(url, base_profile, version)) +
352
+ datatype_constraint_messages(resource, profile_url_without_version(url), version)
353
+ end
354
+ messages_by_profile[url] = profile_messages
355
+ profile_messages.none? { |message| message[:type] == 'error' }
320
356
  end
321
357
 
322
- validation_error_messages << generate_non_conformance_message(item) unless success_profile
358
+ if success_profile
359
+ messages.concat(messages_by_profile[success_profile])
360
+ else
361
+ messages_by_profile.each_value { |profile_messages| messages.concat(profile_messages) }
362
+ validation_error_messages << generate_non_conformance_message(item)
363
+ end
323
364
  end
324
365
  end
325
366
 
@@ -446,19 +487,36 @@ module DaVinciPASTestKit
446
487
  end
447
488
  end
448
489
 
449
- # Validates a Bundle resource against a profile, filtering out entry-resource-level issues
450
- # that are covered by individual resource validation to avoid duplicate messages.
490
+ # Validates a resource against a profile without logging, returning the unfiltered
491
+ # issues as message hashes for the caller to log once profile selection is resolved.
492
+ # @param resource [FHIR::Model] The resource to validate.
493
+ # @param profile_url [String, nil] The profile URL (with version) to validate against,
494
+ # or nil to validate against the base resource definition.
495
+ # @return [Array<Hash>] Message hashes for the issues found.
496
+ def collect_resource_profile_messages(resource, profile_url = nil)
497
+ response_details = []
498
+ resource_is_valid?(resource:, profile_url:,
499
+ add_messages_to_runnable: false,
500
+ validator_response_details: response_details)
501
+ validator_issues_to_messages(response_details.reject(&:filtered))
502
+ end
503
+
504
+ # Same as collect_resource_profile_messages for a Bundle, additionally dropping
505
+ # entry-resource-level issues that are covered by individual resource validation
506
+ # to avoid duplicate messages.
451
507
  # @param bundle [FHIR::Bundle] The Bundle resource to validate.
452
508
  # @param profile_url [String] The profile URL (with version) to validate against.
453
- # @return [Boolean] true if no unfiltered errors remain after removing entry-resource issues.
454
- def bundle_profile_valid?(bundle, profile_url)
509
+ # @return [Array<Hash>] Message hashes for the Bundle-structural issues found.
510
+ def collect_bundle_profile_messages(bundle, profile_url)
455
511
  response_details = []
456
512
  resource_is_valid?(resource: bundle, profile_url:,
457
513
  add_messages_to_runnable: false,
458
514
  validator_response_details: response_details)
459
- bundle_level_issues = reject_entry_resource_issues(response_details)
460
- bundle_level_issues.each { |issue| messages << { type: issue.severity, message: issue.message } }
461
- bundle_level_issues.none? { |issue| issue.severity == 'error' }
515
+ validator_issues_to_messages(reject_entry_resource_issues(response_details))
516
+ end
517
+
518
+ def validator_issues_to_messages(issues)
519
+ issues.map { |issue| { type: issue.severity, message: issue.message } }
462
520
  end
463
521
 
464
522
  # Rejects validator issues that are already filtered or are located at/below
@@ -5,6 +5,7 @@ module DaVinciPASTestKit
5
5
  CLAIM_RESPONSE_PROFILE = 'http://hl7.org/fhir/us/davinci-pas/StructureDefinition/profile-claimresponse'.freeze
6
6
  CLAIM_INQUIRY_PROFILE = 'http://hl7.org/fhir/us/davinci-pas/StructureDefinition/profile-claim-inquiry'.freeze
7
7
  CLAIM_INQUIRY_RESPONSE_PROFILE = 'http://hl7.org/fhir/us/davinci-pas/StructureDefinition/profile-claiminquiryresponse'.freeze
8
+ DATA_ABSENT_REASON_EXTENSION_URL = 'http://hl7.org/fhir/StructureDefinition/data-absent-reason'.freeze
8
9
 
9
10
  BUNDLE_PROFILES_FOR_OPERATION_TYPE = {
10
11
  submit_request: 'http://hl7.org/fhir/us/davinci-pas/StructureDefinition/profile-pas-request-bundle',
@@ -13,6 +14,11 @@ module DaVinciPASTestKit
13
14
  inquire_response: 'http://hl7.org/fhir/us/davinci-pas/StructureDefinition/profile-pas-inquiry-response-bundle'
14
15
  }.freeze
15
16
 
17
+ REFERENCE_NUMBER_EXTENSIONS = {
18
+ authorization_number: 'http://hl7.org/fhir/us/davinci-pas/StructureDefinition/extension-authorizationNumber',
19
+ administration_reference_number: 'http://hl7.org/fhir/us/davinci-pas/StructureDefinition/extension-administrationReferenceNumber'
20
+ }.freeze
21
+
16
22
  def self.bundle_profile_url_for_operation_and_type(operation, type)
17
23
  BUNDLE_PROFILES_FOR_OPERATION_TYPE[:"#{operation}_#{type}"]
18
24
  end
@@ -0,0 +1,166 @@
1
+ require_relative 'pas_constants'
2
+
3
+ module DaVinciPASTestKit
4
+ # Checks for the narrative-only constraints on the PAS Timing and Quantity data type
5
+ # profiles introduced in v2.2 (prof-1, prof-2, prof-3). These constraints have no
6
+ # FHIRPath expression in the StructureDefinitions, so the FHIR validator cannot
7
+ # enforce them and they are verified here instead.
8
+ # See https://hl7.org/fhir/us/davinci-pas/STU2.2/StructureDefinition-profile-datatype-timing.html
9
+ # and https://hl7.org/fhir/us/davinci-pas/STU2.2/StructureDefinition-profile-quantity.html
10
+ module PasDatatypeConstraints
11
+ PAS_STRUCTURE_DEFINITION_BASE = 'http://hl7.org/fhir/us/davinci-pas/StructureDefinition'.freeze
12
+
13
+ TIMING_CALENDAR_PATTERN_EXTENSION_URL = "#{PAS_STRUCTURE_DEFINITION_BASE}/extension-timingcalendarpattern".freeze
14
+ TIMING_DELIVERY_PATTERN_EXTENSION_URL = "#{PAS_STRUCTURE_DEFINITION_BASE}/extension-timingdeliverypattern".freeze
15
+ ITEM_AUTHORIZED_DETAIL_EXTENSION_URL = "#{PAS_STRUCTURE_DEFINITION_BASE}/extension-itemAuthorizedDetail".freeze
16
+
17
+ # Quantity.code is bound (required) to the X12 673 "quantity qualifier" value set,
18
+ # which the terminology validator cannot expand. Requiring this system when a code
19
+ # is present is the machine-checkable part of prof-3.
20
+ X12_QUANTITY_UNITS_SYSTEM = 'https://codesystem.x12.org/005010/673'.freeze
21
+
22
+ # Profiles that apply profile-datatype-timing to elements of their resource.
23
+ TIMING_CONSTRAINED_PROFILE_URLS = [
24
+ "#{PAS_STRUCTURE_DEFINITION_BASE}/profile-devicerequest",
25
+ "#{PAS_STRUCTURE_DEFINITION_BASE}/profile-medicationrequest",
26
+ "#{PAS_STRUCTURE_DEFINITION_BASE}/profile-servicerequest"
27
+ ].freeze
28
+
29
+ # Profiles that apply profile-quantity to elements of their resource, directly or
30
+ # inherited from profile-claim-base (Claim.item.quantity) and
31
+ # profile-claimresponse-base (ClaimResponse.addItem.quantity and the
32
+ # itemAuthorizedDetail quantity sub-extension).
33
+ QUANTITY_CONSTRAINED_PROFILE_URLS = [
34
+ PASConstants::CLAIM_PROFILE_FIRST_SUBMIT,
35
+ PASConstants::CLAIM_PROFILE,
36
+ PASConstants::CLAIM_INQUIRY_PROFILE,
37
+ PASConstants::CLAIM_RESPONSE_PROFILE,
38
+ PASConstants::CLAIM_INQUIRY_RESPONSE_PROFILE,
39
+ "#{PAS_STRUCTURE_DEFINITION_BASE}/profile-servicerequest"
40
+ ].freeze
41
+
42
+ # Evaluates the PAS data type profile constraints for a resource against a profile.
43
+ # @param resource [FHIR::Model] The resource being validated.
44
+ # @param profile_url [String] The canonical (unversioned) profile URL being validated against.
45
+ # @param version [String] The IG version.
46
+ # @return [Array<Hash>] Error message hashes for each constraint violation found.
47
+ def datatype_constraint_messages(resource, profile_url, version)
48
+ return [] unless pas_datatype_constraints_enabled?(version)
49
+
50
+ constraint_messages = []
51
+ if TIMING_CONSTRAINED_PROFILE_URLS.include?(profile_url)
52
+ profiled_timing_elements(resource).each do |timing, location|
53
+ constraint_messages << timing_prof1_message(resource, timing, location)
54
+ end
55
+ end
56
+ if QUANTITY_CONSTRAINED_PROFILE_URLS.include?(profile_url)
57
+ profiled_quantity_elements(resource).each do |quantity, location|
58
+ constraint_messages << quantity_prof2_message(resource, quantity, location)
59
+ constraint_messages << quantity_prof3_message(resource, quantity, location)
60
+ end
61
+ end
62
+ constraint_messages.compact
63
+ end
64
+
65
+ # The Timing and Quantity data type profiles were introduced in PAS v2.2.
66
+ def pas_datatype_constraints_enabled?(version)
67
+ version.to_s.delete_prefix('v').match?(/\A2\.2(\.|\z)/)
68
+ end
69
+
70
+ private
71
+
72
+ # Timing instances the PAS profiles constrain, paired with their element locations.
73
+ def profiled_timing_elements(resource)
74
+ case resource.resourceType
75
+ when 'DeviceRequest', 'ServiceRequest'
76
+ timing = resource.occurrenceTiming
77
+ timing.present? ? [[timing, "#{resource.resourceType}.occurrenceTiming"]] : []
78
+ when 'MedicationRequest'
79
+ Array(resource.dosageInstruction).each_with_index.filter_map do |dosage, index|
80
+ [dosage.timing, "MedicationRequest.dosageInstruction[#{index}].timing"] if dosage.timing.present?
81
+ end
82
+ else
83
+ []
84
+ end
85
+ end
86
+
87
+ # Quantity instances the PAS profiles constrain, paired with their element locations.
88
+ def profiled_quantity_elements(resource)
89
+ case resource.resourceType
90
+ when 'Claim'
91
+ Array(resource.item).each_with_index.filter_map do |item, index|
92
+ [item.quantity, "Claim.item[#{index}].quantity"] if item.quantity.present?
93
+ end
94
+ when 'ClaimResponse'
95
+ claim_response_quantity_elements(resource)
96
+ when 'ServiceRequest'
97
+ quantity = resource.quantityQuantity
98
+ quantity.present? ? [[quantity, 'ServiceRequest.quantityQuantity']] : []
99
+ else
100
+ []
101
+ end
102
+ end
103
+
104
+ def claim_response_quantity_elements(resource)
105
+ add_item_quantities = Array(resource.addItem).each_with_index.filter_map do |add_item, index|
106
+ [add_item.quantity, "ClaimResponse.addItem[#{index}].quantity"] if add_item.quantity.present?
107
+ end
108
+
109
+ authorized_detail_quantities = Array(resource.item).each_with_index.flat_map do |item, index|
110
+ Array(item.extension)
111
+ .select { |extension| extension.url == ITEM_AUTHORIZED_DETAIL_EXTENSION_URL }
112
+ .filter_map do |extension|
113
+ quantity = Array(extension.extension).find { |sub| sub.url == 'quantity' }&.valueQuantity
114
+ if quantity.present?
115
+ [quantity, "ClaimResponse.item[#{index}].extension:authorizedItemDetail.extension:quantity.valueQuantity"]
116
+ end
117
+ end
118
+ end
119
+
120
+ add_item_quantities + authorized_detail_quantities
121
+ end
122
+
123
+ # prof-1: Timings SHALL have at least one of: a count, a frequency and period and
124
+ # periodUnit (with optional frequencyMax and periodMax), a calendarPattern, or a
125
+ # deliveryPattern.
126
+ def timing_prof1_message(resource, timing, location)
127
+ repeat = timing.repeat
128
+ return if repeat&.count.present?
129
+ return if repeat&.frequency.present? && repeat.period.present? && repeat.periodUnit.present?
130
+ return if extension_present?(timing, TIMING_CALENDAR_PATTERN_EXTENSION_URL)
131
+ return if extension_present?(timing, TIMING_DELIVERY_PATTERN_EXTENSION_URL)
132
+
133
+ datatype_constraint_message(resource, location,
134
+ 'Timings SHALL have at least one of: a count, a frequency and period and ' \
135
+ 'periodUnit (with optional frequencyMax and periodMax), a calendarPattern, ' \
136
+ 'or a deliveryPattern (prof-1)')
137
+ end
138
+
139
+ # prof-2: Quantities SHALL have a value and either a unit or a code.
140
+ def quantity_prof2_message(resource, quantity, location)
141
+ return if quantity.value.present? && (quantity.unit.present? || quantity.code.present?)
142
+
143
+ datatype_constraint_message(resource, location,
144
+ 'Quantities SHALL have a value and either a unit or a code (prof-2)')
145
+ end
146
+
147
+ # prof-3: If a quantity code is present, it SHALL use the X12 quantity units.
148
+ def quantity_prof3_message(resource, quantity, location)
149
+ return if quantity.code.blank? || quantity.system == X12_QUANTITY_UNITS_SYSTEM
150
+
151
+ datatype_constraint_message(resource, location,
152
+ 'If a quantity code is present, it SHALL use the X12 quantity units ' \
153
+ "(system #{X12_QUANTITY_UNITS_SYSTEM}) (prof-3)")
154
+ end
155
+
156
+ def extension_present?(element, url)
157
+ Array(element.extension).any? { |extension| extension.url == url }
158
+ end
159
+
160
+ # Mirrors the "ResourceType/id: location: message" format of validator messages.
161
+ def datatype_constraint_message(resource, location, description)
162
+ resource_label = resource.id.present? ? "#{resource.resourceType}/#{resource.id}" : resource.resourceType
163
+ { type: 'error', message: "#{resource_label}: #{location}: #{description}" }
164
+ end
165
+ end
166
+ end
@@ -32,10 +32,10 @@ module DaVinciPASTestKit
32
32
  criteria.is_a?(Hash) ? criteria : {}
33
33
  end
34
34
 
35
- def include_entity?(entity, request_fhir_obj, operation)
35
+ def include_entity?(entity, request_fhir_obj, request_number)
36
36
  criteria = entity_criteria(entity)
37
37
  return false if criteria[REQUEST_RANGE_KEY].present? &&
38
- !request_meets_request_range_criteria?(criteria[REQUEST_RANGE_KEY], operation)
38
+ !ranges_cover_value?(request_number, criteria[REQUEST_RANGE_KEY].to_s)
39
39
  return false if criteria[FHIRPATH_KEY].present? &&
40
40
  !request_meets_inclusion_criteria?(criteria[FHIRPATH_KEY], request_fhir_obj)
41
41
 
@@ -55,26 +55,23 @@ module DaVinciPASTestKit
55
55
  # Request index-based selection criteria
56
56
  # ***********************************************************************
57
57
 
58
- def request_meets_request_range_criteria?(ranges, operation)
59
- ranges_cover_value?(count_previous_successful_requests(operation) + 1, ranges.to_s)
60
- end
61
-
62
58
  def count_previous_successful_requests(operation)
63
59
  requests_repo = Inferno::Repositories::Requests.new
64
60
  previous_requests = requests_repo.requests_for_result(result.id)
65
61
  previous_requests.count { |req| req.url.include?(operation) && req.status == 200 }
66
62
  end
67
63
 
64
+ # An invalid range comes from tester input, so it is reported as a warning on the
65
+ # test and treated as unmet rather than raised, leaving the candidate unselected.
68
66
  def ranges_cover_value?(value, ranges_string)
69
67
  unless /\A(\d+(-\d+)?,)*\d+(-\d+)?\z/.match?(ranges_string)
70
- raise ArgumentError,
71
- "Invalid range string: #{ranges_string.inspect}"
68
+ raise ArgumentError, 'expected comma-separated numbers or ranges such as "1-2,4"'
72
69
  end
73
70
 
74
71
  ranges_string.split(',').any? do |part|
75
72
  if part.include?('-')
76
73
  low, high = part.split('-').map(&:to_i)
77
- raise ArgumentError, "Inverted range in: #{part.inspect}" if low > high
74
+ raise ArgumentError, "#{part.inspect} is inverted" if low > high
78
75
 
79
76
  (low..high).cover?(value)
80
77
  else
@@ -82,7 +79,26 @@ module DaVinciPASTestKit
82
79
  end
83
80
  end
84
81
  rescue ArgumentError => e
85
- raise Inferno::Exceptions::TestSuiteImplementationException.new('pas response range criteria', e.message)
82
+ add_result_warning(
83
+ "Invalid requestRange criteria #{ranges_string.inspect}. The corresponding Bundle was not selected " \
84
+ "(#{e.message})."
85
+ )
86
+ false
87
+ end
88
+
89
+ # ***********************************************************************
90
+ # Tester-facing warnings
91
+ # ***********************************************************************
92
+
93
+ # Records a warning on the result of the waiting test, where the tester will see it,
94
+ # since problems with response selection stem from tester-provided input. The same
95
+ # input is evaluated for every incoming request, so a warning identical to one the
96
+ # result already has is not recorded again.
97
+ def add_result_warning(message)
98
+ messages_repo = Inferno::Repositories::Messages.new
99
+ return if messages_repo.messages_for_result(result.id).any? { |existing| existing.message == message }
100
+
101
+ messages_repo.create(result_id: result.id, type: 'warning', message:)
86
102
  end
87
103
  end
88
104
  end