servactory 2.16.1 → 3.0.0.rc1

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 (128) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +38 -9
  3. data/config/locales/de.yml +134 -0
  4. data/config/locales/en.yml +15 -0
  5. data/config/locales/es.yml +134 -0
  6. data/config/locales/fr.yml +134 -0
  7. data/config/locales/it.yml +134 -0
  8. data/config/locales/ru.yml +15 -0
  9. data/lib/generators/README.md +45 -0
  10. data/lib/generators/servactory/base.rb +82 -0
  11. data/lib/generators/servactory/extension/USAGE +54 -0
  12. data/lib/generators/servactory/extension/extension_generator.rb +41 -0
  13. data/lib/generators/servactory/extension/templates/extension.rb.tt +62 -0
  14. data/lib/generators/servactory/install/USAGE +27 -0
  15. data/lib/generators/servactory/install/install_generator.rb +94 -0
  16. data/lib/generators/servactory/{templates/services/application_service/base.rb → install/templates/application_service/base.rb.tt} +29 -19
  17. data/lib/generators/servactory/{templates/services/application_service/exceptions.rb → install/templates/application_service/exceptions.rb.tt} +1 -1
  18. data/lib/generators/servactory/{templates/services/application_service/result.rb → install/templates/application_service/result.rb.tt} +1 -1
  19. data/lib/generators/servactory/rspec/USAGE +46 -0
  20. data/lib/generators/servactory/rspec/rspec_generator.rb +95 -0
  21. data/lib/generators/servactory/rspec/templates/service_spec.rb.tt +58 -0
  22. data/lib/generators/servactory/service/USAGE +51 -0
  23. data/lib/generators/servactory/service/service_generator.rb +56 -0
  24. data/lib/generators/servactory/service/templates/service.rb.tt +22 -0
  25. data/lib/servactory/actions/collection.rb +56 -1
  26. data/lib/servactory/actions/dsl.rb +11 -11
  27. data/lib/servactory/actions/tools/rules.rb +1 -1
  28. data/lib/servactory/actions/tools/runner.rb +111 -28
  29. data/lib/servactory/base.rb +1 -7
  30. data/lib/servactory/configuration/actions/aliases/collection.rb +5 -0
  31. data/lib/servactory/configuration/actions/rescue_handlers/collection.rb +5 -0
  32. data/lib/servactory/configuration/actions/shortcuts/collection.rb +5 -0
  33. data/lib/servactory/configuration/collection_mode/class_names_collection.rb +5 -0
  34. data/lib/servactory/configuration/config.rb +36 -0
  35. data/lib/servactory/configuration/configurable.rb +95 -0
  36. data/lib/servactory/configuration/dsl.rb +3 -27
  37. data/lib/servactory/configuration/factory.rb +20 -20
  38. data/lib/servactory/configuration/hash_mode/class_names_collection.rb +5 -0
  39. data/lib/servactory/configuration/option_helpers/option_helpers_collection.rb +5 -0
  40. data/lib/servactory/context/warehouse/inputs.rb +2 -2
  41. data/lib/servactory/context/workspace/inputs.rb +2 -2
  42. data/lib/servactory/context/workspace/internals.rb +2 -2
  43. data/lib/servactory/context/workspace/outputs.rb +2 -2
  44. data/lib/servactory/context/workspace.rb +11 -7
  45. data/lib/servactory/dsl.rb +10 -8
  46. data/lib/servactory/maintenance/attributes/tools/validation.rb +1 -1
  47. data/lib/servactory/result.rb +2 -2
  48. data/lib/servactory/stroma/dsl.rb +118 -0
  49. data/lib/servactory/stroma/entry.rb +32 -0
  50. data/lib/servactory/stroma/exceptions/base.rb +45 -0
  51. data/lib/servactory/stroma/exceptions/invalid_hook_type.rb +29 -0
  52. data/lib/servactory/stroma/exceptions/key_already_registered.rb +32 -0
  53. data/lib/servactory/stroma/exceptions/registry_frozen.rb +33 -0
  54. data/lib/servactory/stroma/exceptions/registry_not_finalized.rb +33 -0
  55. data/lib/servactory/stroma/exceptions/unknown_hook_target.rb +39 -0
  56. data/lib/servactory/stroma/hooks/applier.rb +63 -0
  57. data/lib/servactory/stroma/hooks/collection.rb +103 -0
  58. data/lib/servactory/stroma/hooks/factory.rb +80 -0
  59. data/lib/servactory/stroma/hooks/hook.rb +74 -0
  60. data/lib/servactory/stroma/registry.rb +94 -0
  61. data/lib/servactory/stroma/settings/collection.rb +90 -0
  62. data/lib/servactory/stroma/settings/registry_settings.rb +88 -0
  63. data/lib/servactory/stroma/settings/setting.rb +113 -0
  64. data/lib/servactory/stroma/state.rb +59 -0
  65. data/lib/servactory/test_kit/fake_type.rb +23 -0
  66. data/lib/servactory/test_kit/result.rb +45 -0
  67. data/lib/servactory/test_kit/rspec/helpers/argument_matchers.rb +97 -0
  68. data/lib/servactory/test_kit/rspec/helpers/concerns/error_messages.rb +179 -0
  69. data/lib/servactory/test_kit/rspec/helpers/concerns/service_class_validation.rb +74 -0
  70. data/lib/servactory/test_kit/rspec/helpers/fluent.rb +110 -0
  71. data/lib/servactory/test_kit/rspec/helpers/input_validator.rb +149 -0
  72. data/lib/servactory/test_kit/rspec/helpers/legacy.rb +228 -0
  73. data/lib/servactory/test_kit/rspec/helpers/mock_executor.rb +256 -0
  74. data/lib/servactory/test_kit/rspec/helpers/output_validator.rb +121 -0
  75. data/lib/servactory/test_kit/rspec/helpers/service_mock_builder.rb +422 -0
  76. data/lib/servactory/test_kit/rspec/helpers/service_mock_config.rb +129 -0
  77. data/lib/servactory/test_kit/rspec/helpers.rb +51 -84
  78. data/lib/servactory/test_kit/rspec/matchers/base/attribute_matcher.rb +324 -0
  79. data/lib/servactory/test_kit/rspec/matchers/base/submatcher.rb +133 -0
  80. data/lib/servactory/test_kit/rspec/matchers/base/submatcher_context.rb +101 -0
  81. data/lib/servactory/test_kit/rspec/matchers/base/submatcher_registry.rb +205 -0
  82. data/lib/servactory/test_kit/rspec/matchers/concerns/attribute_data_access.rb +100 -0
  83. data/lib/servactory/test_kit/rspec/matchers/concerns/error_message_builder.rb +106 -0
  84. data/lib/servactory/test_kit/rspec/matchers/concerns/value_comparison.rb +97 -0
  85. data/lib/servactory/test_kit/rspec/matchers/have_service_input_matcher.rb +89 -219
  86. data/lib/servactory/test_kit/rspec/matchers/have_service_internal_matcher.rb +74 -166
  87. data/lib/servactory/test_kit/rspec/matchers/have_service_output_matcher.rb +238 -0
  88. data/lib/servactory/test_kit/rspec/matchers/result/be_failure_service_matcher.rb +257 -0
  89. data/lib/servactory/test_kit/rspec/matchers/result/be_success_service_matcher.rb +185 -0
  90. data/lib/servactory/test_kit/rspec/matchers/submatchers/input/default_submatcher.rb +81 -0
  91. data/lib/servactory/test_kit/rspec/matchers/submatchers/input/optional_submatcher.rb +62 -0
  92. data/lib/servactory/test_kit/rspec/matchers/submatchers/input/required_submatcher.rb +93 -0
  93. data/lib/servactory/test_kit/rspec/matchers/submatchers/input/valid_with_submatcher.rb +271 -0
  94. data/lib/servactory/test_kit/rspec/matchers/submatchers/shared/consists_of_submatcher.rb +85 -0
  95. data/lib/servactory/test_kit/rspec/matchers/submatchers/shared/inclusion_submatcher.rb +120 -0
  96. data/lib/servactory/test_kit/rspec/matchers/submatchers/shared/message_submatcher.rb +115 -0
  97. data/lib/servactory/test_kit/rspec/matchers/submatchers/shared/must_submatcher.rb +82 -0
  98. data/lib/servactory/test_kit/rspec/matchers/submatchers/shared/schema_submatcher.rb +102 -0
  99. data/lib/servactory/test_kit/rspec/matchers/submatchers/shared/target_submatcher.rb +125 -0
  100. data/lib/servactory/test_kit/rspec/matchers/submatchers/shared/types_submatcher.rb +77 -0
  101. data/lib/servactory/test_kit/rspec/matchers.rb +126 -285
  102. data/lib/servactory/test_kit/utils/faker.rb +62 -2
  103. data/lib/servactory/tool_kit/dynamic_options/consists_of.rb +166 -0
  104. data/lib/servactory/tool_kit/dynamic_options/format.rb +195 -8
  105. data/lib/servactory/tool_kit/dynamic_options/inclusion.rb +219 -17
  106. data/lib/servactory/tool_kit/dynamic_options/max.rb +143 -0
  107. data/lib/servactory/tool_kit/dynamic_options/min.rb +143 -0
  108. data/lib/servactory/tool_kit/dynamic_options/multiple_of.rb +157 -8
  109. data/lib/servactory/tool_kit/dynamic_options/must.rb +194 -0
  110. data/lib/servactory/tool_kit/dynamic_options/schema.rb +226 -2
  111. data/lib/servactory/tool_kit/dynamic_options/target.rb +252 -0
  112. data/lib/servactory/version.rb +4 -4
  113. data/lib/servactory.rb +4 -0
  114. metadata +73 -19
  115. data/lib/generators/servactory/install_generator.rb +0 -21
  116. data/lib/generators/servactory/rspec_generator.rb +0 -88
  117. data/lib/generators/servactory/service_generator.rb +0 -49
  118. data/lib/servactory/configuration/setup.rb +0 -97
  119. data/lib/servactory/test_kit/rspec/matchers/have_service_attribute_matchers/consists_of_matcher.rb +0 -68
  120. data/lib/servactory/test_kit/rspec/matchers/have_service_attribute_matchers/inclusion_matcher.rb +0 -73
  121. data/lib/servactory/test_kit/rspec/matchers/have_service_attribute_matchers/message_matcher.rb +0 -91
  122. data/lib/servactory/test_kit/rspec/matchers/have_service_attribute_matchers/must_matcher.rb +0 -72
  123. data/lib/servactory/test_kit/rspec/matchers/have_service_attribute_matchers/schema_matcher.rb +0 -92
  124. data/lib/servactory/test_kit/rspec/matchers/have_service_attribute_matchers/types_matcher.rb +0 -72
  125. data/lib/servactory/test_kit/rspec/matchers/have_service_input_matchers/default_matcher.rb +0 -69
  126. data/lib/servactory/test_kit/rspec/matchers/have_service_input_matchers/optional_matcher.rb +0 -63
  127. data/lib/servactory/test_kit/rspec/matchers/have_service_input_matchers/required_matcher.rb +0 -81
  128. data/lib/servactory/test_kit/rspec/matchers/have_service_input_matchers/valid_with_matcher.rb +0 -199
@@ -0,0 +1,81 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Servactory
4
+ module TestKit
5
+ module Rspec
6
+ module Matchers
7
+ module Submatchers
8
+ module Input
9
+ # Submatcher for validating input default values.
10
+ #
11
+ # ## Purpose
12
+ #
13
+ # Validates that a service input has the expected default value.
14
+ # Useful for testing optional inputs with predefined fallback values.
15
+ #
16
+ # ## Usage
17
+ #
18
+ # ```ruby
19
+ # it { is_expected.to have_service_input(:limit).default(10) }
20
+ # it { is_expected.to have_service_input(:enabled).default(true) }
21
+ # it { is_expected.to have_service_input(:options).default({}) }
22
+ # ```
23
+ #
24
+ # ## Comparison
25
+ #
26
+ # Uses case-insensitive string comparison for value matching.
27
+ # Handles nil values specially - matches only when expected is also nil.
28
+ class DefaultSubmatcher < Base::Submatcher
29
+ # Creates a new default submatcher.
30
+ #
31
+ # @param context [Base::SubmatcherContext] The submatcher context
32
+ # @param expected_value [Object] The expected default value
33
+ # @return [DefaultSubmatcher] New submatcher instance
34
+ def initialize(context, expected_value)
35
+ super(context)
36
+ @expected_value = expected_value
37
+ end
38
+
39
+ # Returns description for RSpec output.
40
+ #
41
+ # @return [String] Human-readable description with expected value
42
+ def description
43
+ "default: #{expected_value.inspect}"
44
+ end
45
+
46
+ protected
47
+
48
+ # Checks if the input default matches the expected value.
49
+ #
50
+ # @return [Boolean] True if default values match
51
+ def passes?
52
+ actual_default = attribute_data.fetch(:default)
53
+ @actual_value = actual_default
54
+
55
+ return expected_value.nil? if actual_default.is_a?(NilClass)
56
+
57
+ actual_default.to_s.casecmp(expected_value.to_s).zero?
58
+ end
59
+
60
+ # Builds the failure message for default value validation.
61
+ #
62
+ # @return [String] Failure message with expected vs actual
63
+ def build_failure_message
64
+ <<~MESSAGE
65
+ should have a default value
66
+
67
+ expected #{expected_value.inspect}
68
+ got #{@actual_value.inspect}
69
+ MESSAGE
70
+ end
71
+
72
+ private
73
+
74
+ attr_reader :expected_value
75
+ end
76
+ end
77
+ end
78
+ end
79
+ end
80
+ end
81
+ end
@@ -0,0 +1,62 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Servactory
4
+ module TestKit
5
+ module Rspec
6
+ module Matchers
7
+ module Submatchers
8
+ module Input
9
+ # Submatcher for validating that an input is optional.
10
+ #
11
+ # ## Purpose
12
+ #
13
+ # Validates that a service input has `required: false` option set,
14
+ # meaning the input can be omitted when calling the service.
15
+ #
16
+ # ## Usage
17
+ #
18
+ # ```ruby
19
+ # it { is_expected.to have_service_input(:nickname).optional }
20
+ # it { is_expected.to have_service_input(:description).optional }
21
+ # ```
22
+ #
23
+ # ## Note
24
+ #
25
+ # Mutually exclusive with `required` submatcher - only one can be used
26
+ # per matcher chain.
27
+ class OptionalSubmatcher < Base::Submatcher
28
+ # Returns description for RSpec output.
29
+ #
30
+ # @return [String] Human-readable description
31
+ def description
32
+ "required: false"
33
+ end
34
+
35
+ protected
36
+
37
+ # Checks if the input is optional (required: false).
38
+ #
39
+ # @return [Boolean] True if input is not required
40
+ def passes?
41
+ input_required = attribute_data.fetch(:required).fetch(:is)
42
+ input_required == false
43
+ end
44
+
45
+ # Builds the failure message for optional validation.
46
+ #
47
+ # @return [String] Failure message with expected vs actual
48
+ def build_failure_message
49
+ <<~MESSAGE
50
+ should be optional
51
+
52
+ expected required: false
53
+ got required: true
54
+ MESSAGE
55
+ end
56
+ end
57
+ end
58
+ end
59
+ end
60
+ end
61
+ end
62
+ end
@@ -0,0 +1,93 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Servactory
4
+ module TestKit
5
+ module Rspec
6
+ module Matchers
7
+ module Submatchers
8
+ module Input
9
+ # Submatcher for validating that an input is required.
10
+ #
11
+ # ## Purpose
12
+ #
13
+ # Validates that a service input has `required: true` option set.
14
+ # Optionally validates a custom error message for the required validation.
15
+ #
16
+ # ## Usage
17
+ #
18
+ # ```ruby
19
+ # it { is_expected.to have_service_input(:user_id).required }
20
+ # it { is_expected.to have_service_input(:name).required("Name is mandatory") }
21
+ # ```
22
+ #
23
+ # ## Validation
24
+ #
25
+ # Checks the `:required` option in attribute data where `is: true`.
26
+ # If a custom message is provided, also validates the message matches.
27
+ class RequiredSubmatcher < Base::Submatcher
28
+ # Creates a new required submatcher.
29
+ #
30
+ # @param context [Base::SubmatcherContext] The submatcher context
31
+ # @param custom_message [String, nil] Optional expected error message
32
+ # @return [RequiredSubmatcher] New submatcher instance
33
+ def initialize(context, custom_message = nil)
34
+ super(context)
35
+ @custom_message = custom_message
36
+ end
37
+
38
+ # Returns description for RSpec output.
39
+ #
40
+ # @return [String] Human-readable description
41
+ def description
42
+ "required: true"
43
+ end
44
+
45
+ protected
46
+
47
+ # Checks if the input is required and message matches if specified.
48
+ #
49
+ # @return [Boolean] True if input is required with matching message
50
+ def passes?
51
+ required_data = attribute_data.fetch(:required)
52
+ is_required = required_data.fetch(:is)
53
+
54
+ return false unless is_required == true
55
+ return true unless custom_message.present?
56
+
57
+ actual_message = required_data.fetch(:message) || default_required_message
58
+ actual_message.casecmp(custom_message).zero?
59
+ end
60
+
61
+ # Builds the failure message for required validation.
62
+ #
63
+ # @return [String] Failure message with expected vs actual
64
+ def build_failure_message
65
+ <<~MESSAGE
66
+ should be required
67
+
68
+ expected required: true
69
+ got required: false
70
+ MESSAGE
71
+ end
72
+
73
+ private
74
+
75
+ attr_reader :custom_message
76
+
77
+ # Generates the default I18n message for required validation.
78
+ #
79
+ # @return [String] Localized default required message
80
+ def default_required_message
81
+ I18n.t(
82
+ "#{i18n_root_key}.inputs.validations.required.default_error.default",
83
+ service_class_name: described_class.name,
84
+ input_name: attribute_name
85
+ )
86
+ end
87
+ end
88
+ end
89
+ end
90
+ end
91
+ end
92
+ end
93
+ end
@@ -0,0 +1,271 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Servactory
4
+ module TestKit
5
+ module Rspec
6
+ module Matchers
7
+ module Submatchers
8
+ module Input
9
+ # Submatcher for integration testing of input validation.
10
+ #
11
+ # ## Purpose
12
+ #
13
+ # Performs integration testing of input attributes by calling the
14
+ # actual service and verifying validation behavior. Tests type checks,
15
+ # required/optional status, inclusion, and target validations.
16
+ #
17
+ # ## Usage
18
+ #
19
+ # ```ruby
20
+ # it { is_expected.to have_service_input(:status).valid_with(valid_attributes) }
21
+ # it { is_expected.to have_service_input(:count).valid_with(false) }
22
+ # ```
23
+ #
24
+ # ## Deprecation Notice
25
+ #
26
+ # This submatcher is planned to be decommissioned. Consider using
27
+ # direct service call tests instead.
28
+ #
29
+ # ## Validation Steps
30
+ #
31
+ # 1. `success_passes?` - service succeeds with valid attributes
32
+ # 2. `failure_type_passes?` - fails correctly with wrong type
33
+ # 3. `failure_required_passes?` - fails when required input is nil
34
+ # 4. `failure_optional_passes?` - succeeds when optional input is nil
35
+ # 5. `failure_inclusion_passes?` - fails with value outside inclusion
36
+ # 6. `failure_target_passes?` - fails with value outside target
37
+ class ValidWithSubmatcher < Base::Submatcher # rubocop:disable Metrics/ClassLength
38
+ # Creates a new valid_with submatcher.
39
+ #
40
+ # @param context [Base::SubmatcherContext] The submatcher context
41
+ # @param attributes [Hash, FalseClass] Test attributes or false to skip
42
+ # @return [ValidWithSubmatcher] New submatcher instance
43
+ def initialize(context, attributes)
44
+ super(context)
45
+ @attributes = attributes.is_a?(FalseClass) ? attributes : Servactory::Utils.adapt(attributes)
46
+ end
47
+
48
+ # Returns description for RSpec output.
49
+ #
50
+ # @return [String] Human-readable description
51
+ def description
52
+ "valid_with attribute checking"
53
+ end
54
+
55
+ protected
56
+
57
+ # Runs all validation checks in sequence.
58
+ #
59
+ # @return [Boolean] True if all validation scenarios pass
60
+ def passes?
61
+ return true if attributes.is_a?(FalseClass)
62
+
63
+ success_passes? &&
64
+ failure_type_passes? &&
65
+ failure_required_passes? &&
66
+ failure_optional_passes? &&
67
+ failure_inclusion_passes? &&
68
+ failure_target_passes?
69
+ end
70
+
71
+ # Builds the failure message for valid_with validation.
72
+ #
73
+ # @return [String] Generic failure message
74
+ def build_failure_message
75
+ "should work as expected on the specified attributes based on its options"
76
+ end
77
+
78
+ private
79
+
80
+ attr_reader :attributes
81
+
82
+ # Checks that service succeeds with valid attributes.
83
+ #
84
+ # @return [Boolean] True if service call succeeds
85
+ def success_passes?
86
+ described_class.call!(attributes).success?
87
+ rescue Servactory::Exceptions::Input
88
+ false
89
+ rescue StandardError
90
+ true
91
+ end
92
+
93
+ # Checks that service fails with wrong type.
94
+ #
95
+ # @return [Boolean] True if type validation fails as expected
96
+ def failure_type_passes? # rubocop:disable Metrics/AbcSize, Metrics/MethodLength
97
+ option_types = attribute_data.fetch(:types)
98
+
99
+ prepared_attributes = attributes.dup
100
+ prepared_attributes[attribute_name] = Servactory::TestKit::FakeType.new
101
+
102
+ input_required_message = I18n.t(
103
+ "#{i18n_root_key}.inputs.validations.type.default_error.default",
104
+ service_class_name: described_class.name,
105
+ input_name: attribute_name,
106
+ expected_type: option_types.join(", "),
107
+ given_type: Servactory::TestKit::FakeType.new.class.name
108
+ )
109
+
110
+ expect_failure_with!(prepared_attributes, input_required_message)
111
+ end
112
+
113
+ # Checks that required validation fails when input is nil.
114
+ #
115
+ # @return [Boolean] True if required validation fails as expected
116
+ def failure_required_passes? # rubocop:disable Metrics/AbcSize, Metrics/MethodLength
117
+ input_required = attribute_data.fetch(:required).fetch(:is)
118
+ return true unless input_required
119
+
120
+ prepared_attributes = attributes.dup
121
+ prepared_attributes[attribute_name] = nil
122
+
123
+ input_required_message = attribute_data.fetch(:required).fetch(:message)
124
+
125
+ if input_required_message.nil?
126
+ input_required_message = I18n.t(
127
+ "#{i18n_root_key}.inputs.validations.required.default_error.default",
128
+ service_class_name: described_class.name,
129
+ input_name: attribute_name
130
+ )
131
+ end
132
+
133
+ expect_failure_with!(prepared_attributes, input_required_message)
134
+ end
135
+
136
+ # Checks that optional input accepts nil without failure.
137
+ #
138
+ # @return [Boolean] True if service doesn't fail on nil optional
139
+ def failure_optional_passes?
140
+ input_required = attribute_data.fetch(:required).fetch(:is)
141
+ return true if input_required
142
+
143
+ prepared_attributes = attributes.dup
144
+ prepared_attributes[attribute_name] = nil
145
+
146
+ expect_failure_with!(prepared_attributes, nil)
147
+ end
148
+
149
+ # Checks that inclusion validation fails with wrong value.
150
+ #
151
+ # @return [Boolean] True if inclusion validation fails as expected
152
+ def failure_inclusion_passes? # rubocop:disable Metrics/AbcSize, Metrics/MethodLength
153
+ input_inclusion_in = attribute_data.dig(:inclusion, :in)
154
+ return true if input_inclusion_in.blank?
155
+
156
+ wrong_value = generate_wrong_value_for_inclusion(input_inclusion_in)
157
+
158
+ prepared_attributes = attributes.dup
159
+ prepared_attributes[attribute_name] = wrong_value
160
+
161
+ input_required_message = attribute_data.fetch(:inclusion).fetch(:message)
162
+
163
+ # If message is a Proc, we can't easily evaluate it in the test context
164
+ # (it may require runtime args like input:, value:), so skip message comparison
165
+ if input_required_message.is_a?(Proc)
166
+ return expect_failure_with!(prepared_attributes, :skip_message_check)
167
+ end
168
+
169
+ if input_required_message.nil?
170
+ input_required_message = I18n.t(
171
+ "#{i18n_root_key}.inputs.validations.must.dynamic_options.inclusion.default",
172
+ service_class_name: described_class.name,
173
+ input_name: attribute_name,
174
+ input_inclusion: input_inclusion_in.inspect,
175
+ value: wrong_value.inspect
176
+ )
177
+ end
178
+
179
+ expect_failure_with!(prepared_attributes, input_required_message)
180
+ end
181
+
182
+ # Checks that target validation fails with wrong value.
183
+ #
184
+ # @return [Boolean] True if target validation fails as expected
185
+ def failure_target_passes? # rubocop:disable Metrics/AbcSize, Metrics/MethodLength
186
+ input_target_in = attribute_data.dig(:target, :in)
187
+ return true if input_target_in.blank?
188
+
189
+ target_values = input_target_in.is_a?(Array) ? input_target_in : [input_target_in]
190
+ wrong_value = Servactory::TestKit::Utils::Faker.fetch_value_for(target_values.first.class)
191
+
192
+ prepared_attributes = attributes.dup
193
+ prepared_attributes[attribute_name] = wrong_value
194
+
195
+ input_required_message = attribute_data.fetch(:target).fetch(:message)
196
+
197
+ # If message is a Proc, we can't easily evaluate it in the test context
198
+ # (it may require runtime args like input:, value:), so skip message comparison
199
+ if input_required_message.is_a?(Proc)
200
+ return expect_failure_with!(prepared_attributes, :skip_message_check)
201
+ end
202
+
203
+ if input_required_message.nil?
204
+ input_required_message = I18n.t(
205
+ "#{i18n_root_key}.inputs.validations.must.dynamic_options.target.default",
206
+ service_class_name: described_class.name,
207
+ input_name: attribute_name,
208
+ expected_target: input_target_in.inspect,
209
+ value: wrong_value.inspect
210
+ )
211
+ end
212
+
213
+ expect_failure_with!(prepared_attributes, input_required_message)
214
+ end
215
+
216
+ # Calls service and verifies it fails with expected message.
217
+ #
218
+ # @param prepared_attributes [Hash] Attributes to pass to service
219
+ # @param expected_message [String, Symbol, nil] Expected error message
220
+ # @return [Boolean] True if service fails with expected message
221
+ def expect_failure_with!(prepared_attributes, expected_message)
222
+ described_class.call!(prepared_attributes).success?
223
+ rescue Servactory::Exceptions::Input => e
224
+ return true if expected_message == :skip_message_check # Just verify error was raised
225
+ return false if expected_message.blank?
226
+
227
+ message_to_compare = expected_message.is_a?(Proc) ? expected_message.call : expected_message
228
+ message_to_compare.to_s.casecmp(e.message.to_s).zero?
229
+ rescue Servactory::Exceptions::Internal, Servactory::Exceptions::Output
230
+ true
231
+ end
232
+
233
+ # Generates a value that is outside the inclusion set.
234
+ # Handles Range, Array, and scalar values.
235
+ #
236
+ # @param inclusion_value [Range, Array, Object] The inclusion constraint
237
+ # @return [Object] A value guaranteed to be outside the inclusion set
238
+ def generate_wrong_value_for_inclusion(inclusion_value)
239
+ case inclusion_value
240
+ when Range
241
+ generate_wrong_value_for_range(inclusion_value)
242
+ when Array
243
+ Servactory::TestKit::Utils::Faker.fetch_value_for(inclusion_value.first.class)
244
+ else
245
+ Servactory::TestKit::Utils::Faker.fetch_value_for(inclusion_value.class)
246
+ end
247
+ end
248
+
249
+ # Generates a value outside a Range.
250
+ #
251
+ # @param range [Range] The range constraint
252
+ # @return [Object] A value outside the range
253
+ def generate_wrong_value_for_range(range)
254
+ if range.begin.nil?
255
+ # Beginless range (..100): use value above end
256
+ range.end + 1
257
+ elsif range.end.nil?
258
+ # Endless range (1..): use value below begin
259
+ range.begin - 1
260
+ else
261
+ # Normal range: use value above end
262
+ range.exclude_end? ? range.end : range.end + 1
263
+ end
264
+ end
265
+ end
266
+ end
267
+ end
268
+ end
269
+ end
270
+ end
271
+ end
@@ -0,0 +1,85 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Servactory
4
+ module TestKit
5
+ module Rspec
6
+ module Matchers
7
+ module Submatchers
8
+ module Shared
9
+ # Submatcher for validating Array/Hash element types.
10
+ #
11
+ # ## Purpose
12
+ #
13
+ # Validates that an Array or Hash attribute specifies the expected
14
+ # element types via the `consists_of` option. Used with collection
15
+ # attributes to verify their content type constraints.
16
+ #
17
+ # ## Usage
18
+ #
19
+ # ```ruby
20
+ # it { is_expected.to have_service_input(:items).type(Array).consists_of(Item) }
21
+ # it { is_expected.to have_service_input(:users).type(Array).consists_of(User, Admin) }
22
+ # it { is_expected.to have_service_internal(:data).type(Hash).consists_of(String) }
23
+ # ```
24
+ #
25
+ # ## Note
26
+ #
27
+ # Requires the `:types` option to be set first (via `.type` chain).
28
+ class ConsistsOfSubmatcher < Base::Submatcher
29
+ # Option name in attribute data
30
+ OPTION_NAME = :consists_of
31
+ # Key for the type value within the option
32
+ OPTION_BODY_KEY = :type
33
+
34
+ # Creates a new consists_of submatcher.
35
+ #
36
+ # @param context [Base::SubmatcherContext] The submatcher context
37
+ # @param consists_of_types [Array<Class>] Expected element types
38
+ # @return [ConsistsOfSubmatcher] New submatcher instance
39
+ def initialize(context, consists_of_types)
40
+ super(context)
41
+ @consists_of_types = consists_of_types
42
+ end
43
+
44
+ # Returns description for RSpec output.
45
+ #
46
+ # @return [String] Human-readable description with element types
47
+ def description
48
+ "consists_of: #{consists_of_types.map(&:name).join(', ')}"
49
+ end
50
+
51
+ protected
52
+
53
+ # Checks if the element types match expected types.
54
+ #
55
+ # @return [Boolean] True if element types match (order-independent)
56
+ def passes?
57
+ attribute_consists_of = attribute_data.fetch(OPTION_NAME)
58
+ @actual_types = Array(attribute_consists_of.fetch(OPTION_BODY_KEY))
59
+
60
+ @actual_types.difference(consists_of_types).empty? &&
61
+ consists_of_types.difference(@actual_types).empty?
62
+ end
63
+
64
+ # Builds the failure message for consists_of validation.
65
+ #
66
+ # @return [String] Failure message with expected vs actual types
67
+ def build_failure_message
68
+ <<~MESSAGE
69
+ should be consists_of #{consists_of_types.map(&:name).join(', ')}
70
+
71
+ expected #{consists_of_types.inspect}
72
+ got #{@actual_types.inspect}
73
+ MESSAGE
74
+ end
75
+
76
+ private
77
+
78
+ attr_reader :consists_of_types
79
+ end
80
+ end
81
+ end
82
+ end
83
+ end
84
+ end
85
+ end