light-services 2.2 → 3.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (74) hide show
  1. checksums.yaml +4 -4
  2. data/.github/config/rubocop_linter_action.yml +4 -4
  3. data/.github/workflows/ci.yml +12 -12
  4. data/.gitignore +5 -0
  5. data/.rubocop.yml +77 -7
  6. data/CHANGELOG.md +23 -0
  7. data/CLAUDE.md +139 -0
  8. data/Gemfile +16 -11
  9. data/Gemfile.lock +53 -27
  10. data/README.md +76 -13
  11. data/docs/arguments.md +267 -0
  12. data/docs/best-practices.md +153 -0
  13. data/docs/callbacks.md +476 -0
  14. data/docs/concepts.md +80 -0
  15. data/docs/configuration.md +168 -0
  16. data/docs/context.md +128 -0
  17. data/docs/crud.md +525 -0
  18. data/docs/errors.md +250 -0
  19. data/docs/generators.md +250 -0
  20. data/docs/outputs.md +135 -0
  21. data/docs/pundit-authorization.md +320 -0
  22. data/docs/quickstart.md +134 -0
  23. data/docs/readme.md +100 -0
  24. data/docs/recipes.md +14 -0
  25. data/docs/service-rendering.md +222 -0
  26. data/docs/steps.md +337 -0
  27. data/docs/summary.md +19 -0
  28. data/docs/testing.md +549 -0
  29. data/lib/generators/light_services/install/USAGE +15 -0
  30. data/lib/generators/light_services/install/install_generator.rb +41 -0
  31. data/lib/generators/light_services/install/templates/application_service.rb.tt +8 -0
  32. data/lib/generators/light_services/install/templates/application_service_spec.rb.tt +7 -0
  33. data/lib/generators/light_services/install/templates/initializer.rb.tt +30 -0
  34. data/lib/generators/light_services/service/USAGE +21 -0
  35. data/lib/generators/light_services/service/service_generator.rb +68 -0
  36. data/lib/generators/light_services/service/templates/service.rb.tt +48 -0
  37. data/lib/generators/light_services/service/templates/service_spec.rb.tt +40 -0
  38. data/lib/light/services/base.rb +24 -114
  39. data/lib/light/services/base_with_context.rb +2 -3
  40. data/lib/light/services/callbacks.rb +103 -0
  41. data/lib/light/services/collection.rb +97 -0
  42. data/lib/light/services/concerns/execution.rb +76 -0
  43. data/lib/light/services/concerns/parent_service.rb +34 -0
  44. data/lib/light/services/concerns/state_management.rb +30 -0
  45. data/lib/light/services/config.rb +4 -18
  46. data/lib/light/services/constants.rb +97 -0
  47. data/lib/light/services/dsl/arguments_dsl.rb +84 -0
  48. data/lib/light/services/dsl/outputs_dsl.rb +80 -0
  49. data/lib/light/services/dsl/steps_dsl.rb +205 -0
  50. data/lib/light/services/dsl/validation.rb +132 -0
  51. data/lib/light/services/exceptions.rb +7 -2
  52. data/lib/light/services/messages.rb +19 -31
  53. data/lib/light/services/rspec/matchers/define_argument.rb +174 -0
  54. data/lib/light/services/rspec/matchers/define_output.rb +147 -0
  55. data/lib/light/services/rspec/matchers/define_step.rb +225 -0
  56. data/lib/light/services/rspec/matchers/execute_step.rb +230 -0
  57. data/lib/light/services/rspec/matchers/have_error_on.rb +148 -0
  58. data/lib/light/services/rspec/matchers/have_warning_on.rb +148 -0
  59. data/lib/light/services/rspec/matchers/trigger_callback.rb +138 -0
  60. data/lib/light/services/rspec.rb +15 -0
  61. data/lib/light/services/settings/field.rb +86 -0
  62. data/lib/light/services/settings/step.rb +31 -16
  63. data/lib/light/services/utils.rb +38 -0
  64. data/lib/light/services/version.rb +1 -1
  65. data/lib/light/services.rb +2 -0
  66. data/light-services.gemspec +6 -8
  67. metadata +54 -26
  68. data/lib/light/services/class_based_collection/base.rb +0 -86
  69. data/lib/light/services/class_based_collection/mount.rb +0 -33
  70. data/lib/light/services/collection/arguments.rb +0 -34
  71. data/lib/light/services/collection/base.rb +0 -59
  72. data/lib/light/services/collection/outputs.rb +0 -16
  73. data/lib/light/services/settings/argument.rb +0 -68
  74. data/lib/light/services/settings/output.rb +0 -34
@@ -0,0 +1,230 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Light
4
+ module Services
5
+ module RSpec
6
+ module Matchers
7
+ # Matcher for testing step execution on a service instance
8
+ # NOTE: This matcher requires the service to track executed steps.
9
+ # Add a callback in your service to track execution:
10
+ #
11
+ # after_step_run do |service, step_name|
12
+ # service.executed_steps << step_name
13
+ # end
14
+ #
15
+ # @example Basic usage (requires executed_steps tracking)
16
+ # expect(service).to execute_step(:validate)
17
+ #
18
+ # @example Check step was skipped
19
+ # expect(service).to skip_step(:notify)
20
+ #
21
+ # @example Check multiple steps executed
22
+ # expect(service).to execute_steps(:validate, :process, :save)
23
+ #
24
+ # @example Check execution order
25
+ # expect(service).to execute_steps_in_order(:validate, :process, :save)
26
+ def execute_step(name)
27
+ ExecuteStepMatcher.new(name)
28
+ end
29
+
30
+ def skip_step(name)
31
+ SkipStepMatcher.new(name)
32
+ end
33
+
34
+ def execute_steps(*names)
35
+ ExecuteStepsMatcher.new(names, ordered: false)
36
+ end
37
+
38
+ def execute_steps_in_order(*names)
39
+ ExecuteStepsMatcher.new(names, ordered: true)
40
+ end
41
+
42
+ class ExecuteStepMatcher
43
+ def initialize(name)
44
+ @name = name
45
+ end
46
+
47
+ def matches?(service)
48
+ @service = service
49
+
50
+ return false unless service_tracks_steps?
51
+ return false unless step_executed?
52
+
53
+ true
54
+ end
55
+
56
+ def failure_message
57
+ return tracking_not_available_message unless service_tracks_steps?
58
+
59
+ "expected service to execute step :#{@name}, " \
60
+ "but executed steps were: #{executed_steps.inspect}"
61
+ end
62
+
63
+ def failure_message_when_negated
64
+ "expected service not to execute step :#{@name}"
65
+ end
66
+
67
+ def description
68
+ "execute step :#{@name}"
69
+ end
70
+
71
+ private
72
+
73
+ def service_tracks_steps?
74
+ @service.respond_to?(:executed_steps)
75
+ end
76
+
77
+ def executed_steps
78
+ @service.executed_steps
79
+ end
80
+
81
+ def step_executed?
82
+ executed_steps.include?(@name)
83
+ end
84
+
85
+ def tracking_not_available_message
86
+ "cannot verify step execution because service does not track executed steps. " \
87
+ "Add `after_step_run { |service, step| service.executed_steps << step }` to your service."
88
+ end
89
+ end
90
+
91
+ class SkipStepMatcher
92
+ def initialize(name)
93
+ @name = name
94
+ end
95
+
96
+ def matches?(service)
97
+ @service = service
98
+
99
+ return false unless service_tracks_steps?
100
+ return false unless step_skipped?
101
+
102
+ true
103
+ end
104
+
105
+ def failure_message
106
+ return tracking_not_available_message unless service_tracks_steps?
107
+
108
+ "expected service to skip step :#{@name}, but it was executed. " \
109
+ "Executed steps: #{executed_steps.inspect}"
110
+ end
111
+
112
+ def failure_message_when_negated
113
+ "expected service not to skip step :#{@name} (expected it to execute)"
114
+ end
115
+
116
+ def description
117
+ "skip step :#{@name}"
118
+ end
119
+
120
+ private
121
+
122
+ def service_tracks_steps?
123
+ @service.respond_to?(:executed_steps)
124
+ end
125
+
126
+ def executed_steps
127
+ @service.executed_steps
128
+ end
129
+
130
+ def step_skipped?
131
+ !executed_steps.include?(@name)
132
+ end
133
+
134
+ def tracking_not_available_message
135
+ "cannot verify step execution because service does not track executed steps. " \
136
+ "Add `after_step_run { |service, step| service.executed_steps << step }` to your service."
137
+ end
138
+ end
139
+
140
+ class ExecuteStepsMatcher
141
+ def initialize(names, ordered:)
142
+ @names = names
143
+ @ordered = ordered
144
+ end
145
+
146
+ def matches?(service)
147
+ @service = service
148
+ @missing_steps = []
149
+
150
+ return false unless service_tracks_steps?
151
+ return false unless all_steps_executed?
152
+ return false unless order_matches?
153
+
154
+ true
155
+ end
156
+
157
+ def failure_message
158
+ return tracking_not_available_message unless service_tracks_steps?
159
+ return missing_steps_failure_message unless all_steps_executed?
160
+ return order_failure_message unless order_matches?
161
+
162
+ ""
163
+ end
164
+
165
+ def failure_message_when_negated
166
+ if @ordered
167
+ "expected service not to execute steps #{@names.inspect} in that order"
168
+ else
169
+ "expected service not to execute steps #{@names.inspect}"
170
+ end
171
+ end
172
+
173
+ def description
174
+ if @ordered
175
+ "execute steps #{@names.inspect} in order"
176
+ else
177
+ "execute steps #{@names.inspect}"
178
+ end
179
+ end
180
+
181
+ private
182
+
183
+ def service_tracks_steps?
184
+ @service.respond_to?(:executed_steps)
185
+ end
186
+
187
+ def executed_steps
188
+ @service.executed_steps
189
+ end
190
+
191
+ def all_steps_executed?
192
+ @missing_steps = @names.reject { |name| executed_steps.include?(name) }
193
+ @missing_steps.empty?
194
+ end
195
+
196
+ def order_matches?
197
+ return true unless @ordered
198
+
199
+ # Check if the expected steps appear in the same order in executed steps
200
+ last_index = -1
201
+ @names.all? do |name|
202
+ current_index = executed_steps.index(name)
203
+ return false unless current_index
204
+ return false unless current_index > last_index
205
+
206
+ last_index = current_index
207
+ true
208
+ end
209
+ end
210
+
211
+ def missing_steps_failure_message
212
+ "expected service to execute steps #{@names.inspect}, " \
213
+ "but missing: #{@missing_steps.inspect}. " \
214
+ "Executed steps: #{executed_steps.inspect}"
215
+ end
216
+
217
+ def order_failure_message
218
+ "expected service to execute steps #{@names.inspect} in that order, " \
219
+ "but actual execution order was: #{executed_steps.inspect}"
220
+ end
221
+
222
+ def tracking_not_available_message
223
+ "cannot verify step execution because service does not track executed steps. " \
224
+ "Add `after_step_run { |service, step| service.executed_steps << step }` to your service."
225
+ end
226
+ end
227
+ end
228
+ end
229
+ end
230
+ end
@@ -0,0 +1,148 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Light
4
+ module Services
5
+ module RSpec
6
+ module Matchers
7
+ # Matcher for testing errors on a service instance
8
+ #
9
+ # @example Basic usage
10
+ # expect(service).to have_error_on(:name)
11
+ #
12
+ # @example With specific message
13
+ # expect(service).to have_error_on(:name).with_message("can't be blank")
14
+ #
15
+ # @example With message matching regex
16
+ # expect(service).to have_error_on(:base).with_message(/invalid/)
17
+ #
18
+ # @example Check multiple error keys
19
+ # expect(service).to have_errors_on(:name, :email)
20
+ def have_error_on(key)
21
+ HaveErrorOnMatcher.new(key)
22
+ end
23
+
24
+ def have_errors_on(*keys)
25
+ HaveErrorsOnMatcher.new(keys)
26
+ end
27
+
28
+ class HaveErrorOnMatcher
29
+ def initialize(key)
30
+ @key = key
31
+ @expected_message = nil
32
+ end
33
+
34
+ def with_message(message)
35
+ @expected_message = message
36
+ self
37
+ end
38
+
39
+ def matches?(service)
40
+ @service = service
41
+
42
+ return false unless has_error_key?
43
+ return false unless message_matches?
44
+
45
+ true
46
+ end
47
+
48
+ def failure_message
49
+ unless has_error_key?
50
+ return "expected service to have error on :#{@key}, but errors were: #{errors_summary}"
51
+ end
52
+ return message_failure_message unless message_matches?
53
+
54
+ ""
55
+ end
56
+
57
+ def failure_message_when_negated
58
+ if @expected_message
59
+ "expected service not to have error on :#{@key} with message #{@expected_message.inspect}"
60
+ else
61
+ "expected service not to have error on :#{@key}"
62
+ end
63
+ end
64
+
65
+ def description
66
+ desc = "have error on :#{@key}"
67
+ desc += " with message #{@expected_message.inspect}" if @expected_message
68
+ desc
69
+ end
70
+
71
+ private
72
+
73
+ def has_error_key?
74
+ @service.errors.key?(@key)
75
+ end
76
+
77
+ def message_matches?
78
+ return true if @expected_message.nil?
79
+
80
+ error_messages = @service.errors[@key].map(&:to_s)
81
+
82
+ case @expected_message
83
+ when Regexp
84
+ error_messages.any? { |msg| msg.match?(@expected_message) }
85
+ else
86
+ error_messages.include?(@expected_message.to_s)
87
+ end
88
+ end
89
+
90
+ def message_failure_message
91
+ actual_messages = @service.errors[@key].map(&:to_s)
92
+ "expected service error on :#{@key} to include message #{@expected_message.inspect}, " \
93
+ "but messages were: #{actual_messages.inspect}"
94
+ end
95
+
96
+ def errors_summary
97
+ if @service.errors.empty?
98
+ "empty"
99
+ else
100
+ @service.errors.to_h.inspect
101
+ end
102
+ end
103
+ end
104
+
105
+ class HaveErrorsOnMatcher
106
+ def initialize(keys)
107
+ @keys = keys
108
+ end
109
+
110
+ def matches?(service)
111
+ @service = service
112
+ @missing_keys = []
113
+
114
+ @keys.each do |key|
115
+ @missing_keys << key unless @service.errors.key?(key)
116
+ end
117
+
118
+ @missing_keys.empty?
119
+ end
120
+
121
+ def failure_message
122
+ "expected service to have errors on #{@keys.inspect}, " \
123
+ "but missing errors on: #{@missing_keys.inspect}. " \
124
+ "Actual errors: #{errors_summary}"
125
+ end
126
+
127
+ def failure_message_when_negated
128
+ "expected service not to have errors on #{@keys.inspect}"
129
+ end
130
+
131
+ def description
132
+ "have errors on #{@keys.inspect}"
133
+ end
134
+
135
+ private
136
+
137
+ def errors_summary
138
+ if @service.errors.empty?
139
+ "empty"
140
+ else
141
+ @service.errors.to_h.inspect
142
+ end
143
+ end
144
+ end
145
+ end
146
+ end
147
+ end
148
+ end
@@ -0,0 +1,148 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Light
4
+ module Services
5
+ module RSpec
6
+ module Matchers
7
+ # Matcher for testing warnings on a service instance
8
+ #
9
+ # @example Basic usage
10
+ # expect(service).to have_warning_on(:name)
11
+ #
12
+ # @example With specific message
13
+ # expect(service).to have_warning_on(:name).with_message("was replaced")
14
+ #
15
+ # @example With message matching regex
16
+ # expect(service).to have_warning_on(:base).with_message(/deprecated/)
17
+ #
18
+ # @example Check multiple warning keys
19
+ # expect(service).to have_warnings_on(:name, :email)
20
+ def have_warning_on(key)
21
+ HaveWarningOnMatcher.new(key)
22
+ end
23
+
24
+ def have_warnings_on(*keys)
25
+ HaveWarningsOnMatcher.new(keys)
26
+ end
27
+
28
+ class HaveWarningOnMatcher
29
+ def initialize(key)
30
+ @key = key
31
+ @expected_message = nil
32
+ end
33
+
34
+ def with_message(message)
35
+ @expected_message = message
36
+ self
37
+ end
38
+
39
+ def matches?(service)
40
+ @service = service
41
+
42
+ return false unless has_warning_key?
43
+ return false unless message_matches?
44
+
45
+ true
46
+ end
47
+
48
+ def failure_message
49
+ unless has_warning_key?
50
+ return "expected service to have warning on :#{@key}, but warnings were: #{warnings_summary}"
51
+ end
52
+ return message_failure_message unless message_matches?
53
+
54
+ ""
55
+ end
56
+
57
+ def failure_message_when_negated
58
+ if @expected_message
59
+ "expected service not to have warning on :#{@key} with message #{@expected_message.inspect}"
60
+ else
61
+ "expected service not to have warning on :#{@key}"
62
+ end
63
+ end
64
+
65
+ def description
66
+ desc = "have warning on :#{@key}"
67
+ desc += " with message #{@expected_message.inspect}" if @expected_message
68
+ desc
69
+ end
70
+
71
+ private
72
+
73
+ def has_warning_key?
74
+ @service.warnings.key?(@key)
75
+ end
76
+
77
+ def message_matches?
78
+ return true if @expected_message.nil?
79
+
80
+ warning_messages = @service.warnings[@key].map(&:to_s)
81
+
82
+ case @expected_message
83
+ when Regexp
84
+ warning_messages.any? { |msg| msg.match?(@expected_message) }
85
+ else
86
+ warning_messages.include?(@expected_message.to_s)
87
+ end
88
+ end
89
+
90
+ def message_failure_message
91
+ actual_messages = @service.warnings[@key].map(&:to_s)
92
+ "expected service warning on :#{@key} to include message #{@expected_message.inspect}, " \
93
+ "but messages were: #{actual_messages.inspect}"
94
+ end
95
+
96
+ def warnings_summary
97
+ if @service.warnings.empty?
98
+ "empty"
99
+ else
100
+ @service.warnings.to_h.inspect
101
+ end
102
+ end
103
+ end
104
+
105
+ class HaveWarningsOnMatcher
106
+ def initialize(keys)
107
+ @keys = keys
108
+ end
109
+
110
+ def matches?(service)
111
+ @service = service
112
+ @missing_keys = []
113
+
114
+ @keys.each do |key|
115
+ @missing_keys << key unless @service.warnings.key?(key)
116
+ end
117
+
118
+ @missing_keys.empty?
119
+ end
120
+
121
+ def failure_message
122
+ "expected service to have warnings on #{@keys.inspect}, " \
123
+ "but missing warnings on: #{@missing_keys.inspect}. " \
124
+ "Actual warnings: #{warnings_summary}"
125
+ end
126
+
127
+ def failure_message_when_negated
128
+ "expected service not to have warnings on #{@keys.inspect}"
129
+ end
130
+
131
+ def description
132
+ "have warnings on #{@keys.inspect}"
133
+ end
134
+
135
+ private
136
+
137
+ def warnings_summary
138
+ if @service.warnings.empty?
139
+ "empty"
140
+ else
141
+ @service.warnings.to_h.inspect
142
+ end
143
+ end
144
+ end
145
+ end
146
+ end
147
+ end
148
+ end
@@ -0,0 +1,138 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Light
4
+ module Services
5
+ module RSpec
6
+ module Matchers
7
+ # Matcher for testing callback execution on a service instance
8
+ # NOTE: This matcher requires the service to track callback execution.
9
+ # Add tracking in your callbacks:
10
+ #
11
+ # before_service_run do |service|
12
+ # service.callback_log << :before_service_run
13
+ # end
14
+ #
15
+ # after_step_run do |service, step_name|
16
+ # service.callback_log << [:after_step_run, step_name]
17
+ # end
18
+ #
19
+ # @example Basic callback check (requires callback_log tracking)
20
+ # expect(service).to trigger_callback(:before_service_run)
21
+ #
22
+ # @example Step-specific callback
23
+ # expect(service).to trigger_callback(:after_step_run).for_step(:validate)
24
+ #
25
+ # @example Check callback was not triggered
26
+ # expect(service).not_to trigger_callback(:on_service_failure)
27
+ def trigger_callback(callback_name)
28
+ TriggerCallbackMatcher.new(callback_name)
29
+ end
30
+
31
+ class TriggerCallbackMatcher
32
+ VALID_CALLBACKS = [
33
+ :before_service_run,
34
+ :after_service_run,
35
+ :around_service_run,
36
+ :on_service_success,
37
+ :on_service_failure,
38
+ :before_step_run,
39
+ :after_step_run,
40
+ :around_step_run,
41
+ :on_step_success,
42
+ :on_step_failure,
43
+ :on_step_crash,
44
+ ].freeze
45
+
46
+ STEP_CALLBACKS = [
47
+ :before_step_run,
48
+ :after_step_run,
49
+ :around_step_run,
50
+ :on_step_success,
51
+ :on_step_failure,
52
+ :on_step_crash,
53
+ ].freeze
54
+
55
+ def initialize(callback_name)
56
+ @callback_name = callback_name
57
+ @step_name = nil
58
+ end
59
+
60
+ def for_step(step_name)
61
+ @step_name = step_name
62
+ self
63
+ end
64
+
65
+ def matches?(service)
66
+ @service = service
67
+
68
+ return false unless service_tracks_callbacks?
69
+ return false unless callback_triggered?
70
+
71
+ true
72
+ end
73
+
74
+ def failure_message
75
+ return tracking_not_available_message unless service_tracks_callbacks?
76
+
77
+ if @step_name
78
+ "expected service to trigger callback :#{@callback_name} for step :#{@step_name}, " \
79
+ "but callback log was: #{callback_log.inspect}"
80
+ else
81
+ "expected service to trigger callback :#{@callback_name}, " \
82
+ "but callback log was: #{callback_log.inspect}"
83
+ end
84
+ end
85
+
86
+ def failure_message_when_negated
87
+ if @step_name
88
+ "expected service not to trigger callback :#{@callback_name} for step :#{@step_name}"
89
+ else
90
+ "expected service not to trigger callback :#{@callback_name}"
91
+ end
92
+ end
93
+
94
+ def description
95
+ desc = "trigger callback :#{@callback_name}"
96
+ desc += " for step :#{@step_name}" if @step_name
97
+ desc
98
+ end
99
+
100
+ private
101
+
102
+ def service_tracks_callbacks?
103
+ @service.respond_to?(:callback_log)
104
+ end
105
+
106
+ def callback_log
107
+ @service.callback_log
108
+ end
109
+
110
+ def callback_triggered?
111
+ if @step_name
112
+ # Look for step-specific callback entry like [:after_step_run, :validate]
113
+ callback_log.include?([@callback_name, @step_name])
114
+ else
115
+ # Look for service-level callback or any occurrence of the callback name
116
+ callback_log.any? do |entry|
117
+ case entry
118
+ when Symbol
119
+ entry == @callback_name
120
+ when Array
121
+ entry.first == @callback_name
122
+ else
123
+ false
124
+ end
125
+ end
126
+ end
127
+ end
128
+
129
+ def tracking_not_available_message
130
+ "cannot verify callback execution because service does not track callbacks. " \
131
+ "Add callback tracking to your service, e.g.: " \
132
+ "`before_service_run { |service| service.callback_log << :before_service_run }`"
133
+ end
134
+ end
135
+ end
136
+ end
137
+ end
138
+ end
@@ -0,0 +1,15 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "light/services"
4
+
5
+ require_relative "rspec/matchers/define_argument"
6
+ require_relative "rspec/matchers/define_output"
7
+ require_relative "rspec/matchers/define_step"
8
+ require_relative "rspec/matchers/have_error_on"
9
+ require_relative "rspec/matchers/have_warning_on"
10
+ require_relative "rspec/matchers/execute_step"
11
+ require_relative "rspec/matchers/trigger_callback"
12
+
13
+ RSpec.configure do |config|
14
+ config.include Light::Services::RSpec::Matchers
15
+ end