operandi 5.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 (107) hide show
  1. checksums.yaml +7 -0
  2. data/.cursor/rules/services/RULE.md +269 -0
  3. data/.cursor/rules/services-rspec/RULE.md +354 -0
  4. data/.github/dependabot.yml +11 -0
  5. data/.github/workflows/ci.yml +77 -0
  6. data/.gitignore +25 -0
  7. data/.rspec +3 -0
  8. data/.rubocop.yml +134 -0
  9. data/.ruby-version +1 -0
  10. data/.vscode/cspell.json +18 -0
  11. data/.vscode/project-words.txt +12 -0
  12. data/AGENTS.md +139 -0
  13. data/CHANGELOG.md +111 -0
  14. data/CLAUDE.md +139 -0
  15. data/CODE_OF_CONDUCT.md +74 -0
  16. data/Gemfile +28 -0
  17. data/Gemfile.lock +149 -0
  18. data/LICENSE.txt +21 -0
  19. data/README.md +172 -0
  20. data/Rakefile +8 -0
  21. data/bin/console +15 -0
  22. data/bin/setup +8 -0
  23. data/config/default.yml +57 -0
  24. data/docs/README.md +105 -0
  25. data/docs/SUMMARY.md +31 -0
  26. data/docs/arguments.md +275 -0
  27. data/docs/best-practices.md +153 -0
  28. data/docs/callbacks.md +475 -0
  29. data/docs/concepts.md +79 -0
  30. data/docs/configuration.md +218 -0
  31. data/docs/context.md +128 -0
  32. data/docs/crud.md +525 -0
  33. data/docs/errors.md +331 -0
  34. data/docs/generators.md +250 -0
  35. data/docs/outputs.md +150 -0
  36. data/docs/pundit-authorization.md +320 -0
  37. data/docs/quickstart.md +133 -0
  38. data/docs/recipes.md +14 -0
  39. data/docs/rubocop.md +430 -0
  40. data/docs/ruby-lsp.md +121 -0
  41. data/docs/service-rendering.md +222 -0
  42. data/docs/sorbet-runtime.md +283 -0
  43. data/docs/steps.md +438 -0
  44. data/docs/tapioca.md +188 -0
  45. data/docs/testing.md +548 -0
  46. data/lib/generators/operandi/install/USAGE +15 -0
  47. data/lib/generators/operandi/install/install_generator.rb +45 -0
  48. data/lib/generators/operandi/install/templates/application_service.rb.tt +8 -0
  49. data/lib/generators/operandi/install/templates/application_service_spec.rb.tt +7 -0
  50. data/lib/generators/operandi/install/templates/initializer.rb.tt +30 -0
  51. data/lib/generators/operandi/service/USAGE +21 -0
  52. data/lib/generators/operandi/service/service_generator.rb +80 -0
  53. data/lib/generators/operandi/service/templates/service.rb.tt +48 -0
  54. data/lib/generators/operandi/service/templates/service_spec.rb.tt +40 -0
  55. data/lib/operandi/base.rb +230 -0
  56. data/lib/operandi/base_with_context.rb +57 -0
  57. data/lib/operandi/callbacks.rb +353 -0
  58. data/lib/operandi/collection.rb +166 -0
  59. data/lib/operandi/concerns/execution.rb +80 -0
  60. data/lib/operandi/concerns/parent_service.rb +32 -0
  61. data/lib/operandi/concerns/state_management.rb +34 -0
  62. data/lib/operandi/config.rb +142 -0
  63. data/lib/operandi/constants.rb +96 -0
  64. data/lib/operandi/dsl/arguments_dsl.rb +83 -0
  65. data/lib/operandi/dsl/outputs_dsl.rb +79 -0
  66. data/lib/operandi/dsl/steps_dsl.rb +206 -0
  67. data/lib/operandi/dsl/validation.rb +171 -0
  68. data/lib/operandi/exceptions.rb +66 -0
  69. data/lib/operandi/message.rb +52 -0
  70. data/lib/operandi/messages.rb +185 -0
  71. data/lib/operandi/rspec/matchers/define_argument.rb +172 -0
  72. data/lib/operandi/rspec/matchers/define_output.rb +145 -0
  73. data/lib/operandi/rspec/matchers/define_step.rb +223 -0
  74. data/lib/operandi/rspec/matchers/execute_step.rb +228 -0
  75. data/lib/operandi/rspec/matchers/have_error_on.rb +144 -0
  76. data/lib/operandi/rspec/matchers/have_warning_on.rb +146 -0
  77. data/lib/operandi/rspec/matchers/trigger_callback.rb +136 -0
  78. data/lib/operandi/rspec.rb +15 -0
  79. data/lib/operandi/rubocop/cop/operandi/argument_type_required.rb +52 -0
  80. data/lib/operandi/rubocop/cop/operandi/condition_method_exists.rb +173 -0
  81. data/lib/operandi/rubocop/cop/operandi/deprecated_accessors.rb +113 -0
  82. data/lib/operandi/rubocop/cop/operandi/deprecated_methods.rb +113 -0
  83. data/lib/operandi/rubocop/cop/operandi/dsl_order.rb +181 -0
  84. data/lib/operandi/rubocop/cop/operandi/missing_private_keyword.rb +102 -0
  85. data/lib/operandi/rubocop/cop/operandi/no_direct_instantiation.rb +66 -0
  86. data/lib/operandi/rubocop/cop/operandi/no_hash_argument.rb +101 -0
  87. data/lib/operandi/rubocop/cop/operandi/output_type_required.rb +52 -0
  88. data/lib/operandi/rubocop/cop/operandi/prefer_fail_method.rb +112 -0
  89. data/lib/operandi/rubocop/cop/operandi/prefer_optional_over_default_nil.rb +124 -0
  90. data/lib/operandi/rubocop/cop/operandi/redundant_optional.rb +103 -0
  91. data/lib/operandi/rubocop/cop/operandi/reserved_name.rb +56 -0
  92. data/lib/operandi/rubocop/cop/operandi/step_method_exists.rb +134 -0
  93. data/lib/operandi/rubocop.rb +22 -0
  94. data/lib/operandi/settings/field.rb +147 -0
  95. data/lib/operandi/settings/step.rb +105 -0
  96. data/lib/operandi/utils.rb +36 -0
  97. data/lib/operandi/version.rb +5 -0
  98. data/lib/operandi.rb +11 -0
  99. data/lib/ruby_lsp/operandi/addon.rb +37 -0
  100. data/lib/ruby_lsp/operandi/definition.rb +134 -0
  101. data/lib/ruby_lsp/operandi/indexing_enhancement.rb +224 -0
  102. data/lib/tapioca/dsl/compilers/operandi.rb +378 -0
  103. data/operandi.gemspec +33 -0
  104. data/rbi/operandi.rbi +197 -0
  105. data/sorbet/cache/data.mdb +0 -0
  106. data/sorbet/cache/lock.mdb +0 -0
  107. metadata +151 -0
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 5cf8967dddc57ff93d3e1d683b9415cbf0e02cfd3d3d195c0ca8f2e12c54213a
4
+ data.tar.gz: f7ae3f36279bd6fc766501bbd4e189bce375aca6e05822b98d3a08184ed04be3
5
+ SHA512:
6
+ metadata.gz: d4dbda88f7a0ba6f3caebd978e45770a26471759ad2a9e2990409dc6c21711e102eef1de25dabbd990a39ea5555563ca133dfead3f666dce9c2afb5f374cc2d5
7
+ data.tar.gz: 9dcbc24eb7d4c955d90fbfaa3c019a1472c6c2119ef91c0fbc6a4114853c860f7a1f41e9b7f2ffc214e49f719e22d4626af2f3bbd9c0575040d70e7e8d0479cb
@@ -0,0 +1,269 @@
1
+ ---
2
+ description: "Rules for managing Operandi - Ruby service objects with arguments, steps, and outputs"
3
+ globs: "**/services/**/*.rb"
4
+ alwaysApply: false
5
+ ---
6
+
7
+ # Operandi Creation Rules
8
+
9
+ When creating or modifying services that inherit from `Operandi::Base` or `ApplicationService`, follow these patterns.
10
+
11
+ ## Service Structure
12
+
13
+ Always structure services in this order:
14
+ 1. Configuration (`config`) if needed
15
+ 2. Arguments (`arg`)
16
+ 3. Steps (`step`)
17
+ 4. Outputs (`output`)
18
+ 5. Private methods implementing steps
19
+
20
+ ```ruby
21
+ class ModelName::ActionName < ApplicationService
22
+ # Arguments
23
+ arg :required_param, type: String
24
+ arg :optional_param, type: Integer, optional: true, default: 0
25
+
26
+ # Steps
27
+ step :validate
28
+ step :perform
29
+ step :cleanup, always: true
30
+
31
+ # Outputs
32
+ output :result, type: Hash
33
+
34
+ private
35
+
36
+ def validate
37
+ errors.add(:required_param, "can't be blank") if required_param.blank?
38
+ end
39
+
40
+ def perform
41
+ self.result = { success: true }
42
+ end
43
+
44
+ def cleanup
45
+ # Always runs
46
+ end
47
+ end
48
+ ```
49
+
50
+ ## Naming Conventions
51
+
52
+ - **Class name**: `ModelName::ActionVerb` (e.g., `User::Create`, `Order::Process`)
53
+ - **File path**: `app/services/model_name/action_verb.rb`
54
+ - **Step methods**: Use descriptive verbs (`validate`, `authorize`, `build_record`, `save`)
55
+
56
+ ## Arguments
57
+
58
+ ```ruby
59
+ # Required with type
60
+ arg :user_id, type: Integer
61
+
62
+ # Optional with default
63
+ arg :notify, type: [TrueClass, FalseClass], default: true
64
+
65
+ # Context argument (auto-passed to child services)
66
+ arg :current_user, type: User, optional: true, context: true
67
+
68
+ # Multiple allowed types
69
+ arg :id, type: [String, Integer]
70
+
71
+ # Proc default (evaluated at runtime)
72
+ arg :created_at, type: Time, default: -> { Time.current }
73
+ ```
74
+
75
+ ## Steps
76
+
77
+ ```ruby
78
+ # Basic step
79
+ step :process
80
+
81
+ # Conditional execution
82
+ step :send_email, if: :should_notify?
83
+ step :skip_audit, unless: :production?
84
+
85
+ # Always run (even after errors, unless stop! or stop_immediately! are called)
86
+ step :cleanup, always: true
87
+
88
+ # Insertion points (for inheritance)
89
+ step :log_action, before: :save
90
+ step :broadcast, after: :save
91
+ ```
92
+
93
+ ## Outputs
94
+
95
+ ```ruby
96
+ # Required output
97
+ output :user, type: User
98
+
99
+ # Optional output
100
+ output :metadata, type: Hash, optional: true
101
+
102
+ # With default
103
+ output :status, type: String, default: "completed"
104
+ ```
105
+
106
+ ## Error Handling
107
+
108
+ ```ruby
109
+ # Add error (stops execution of next steps by default)
110
+ errors.add(:email, "is invalid")
111
+
112
+ # Add to base
113
+ errors.add(:base, "Something went wrong")
114
+ fail!("Something went wrong") # Shorthand
115
+
116
+ # Copy from ActiveRecord model
117
+ errors.copy_from(user)
118
+
119
+ # Stop next steps without error (commits transaction)
120
+ stop!
121
+
122
+ # Stop with rollback
123
+ fail_immediately!("Critical error")
124
+ ```
125
+
126
+ ## Service Chaining
127
+
128
+ ```ruby
129
+ # Run in same context (shared arguments, errors propagate, rollback propagate)
130
+ ChildService.with(self).run(param: value)
131
+
132
+ # Independent service (separate transaction)
133
+ OtherService.run(param: value)
134
+ ```
135
+
136
+ ## Configuration
137
+
138
+ ```ruby
139
+ config use_transactions: true # Wrap in DB transaction (default)
140
+ config raise_on_error: false # Raise exception on error
141
+ config break_on_error: true # Stop on error (default)
142
+ config rollback_on_error: true # Rollback on error (default)
143
+ ```
144
+
145
+ ## Testing Pattern
146
+
147
+ ```ruby
148
+ RSpec.describe ModelName::ActionName do
149
+ describe ".run" do
150
+ subject(:service) { described_class.run(params) }
151
+ let(:params) { { required_param: "value" } }
152
+
153
+ context "when successful" do
154
+ it { is_expected.to be_successful }
155
+ it { expect(service.result).to eq(expected) }
156
+ end
157
+
158
+ context "when validation fails" do
159
+ let(:params) { { required_param: "" } }
160
+
161
+ it { is_expected.to be_failed }
162
+ it { is_expected.to have_error_on(:required_param) }
163
+ end
164
+ end
165
+ end
166
+ ```
167
+
168
+ ## Common Patterns
169
+
170
+ ### CRUD Create
171
+ ```ruby
172
+ class User::Create < CreateRecordService
173
+ private
174
+
175
+ def entity_class
176
+ User
177
+ end
178
+
179
+ def filtered_params
180
+ params.slice(:name, :email, :role)
181
+ end
182
+ end
183
+ ```
184
+
185
+ ### With Authorization
186
+ ```ruby
187
+ step :authorize
188
+ step :perform
189
+
190
+ def authorize
191
+ fail!("Not authorized") unless current_user&.admin?
192
+ end
193
+ ```
194
+
195
+ ### With Callbacks
196
+ ```ruby
197
+ before_service_run :log_start
198
+ after_service_run :log_finish
199
+ on_service_failure :notify_error
200
+ ```
201
+
202
+ ## Using Sorbet Runtime Types
203
+
204
+ Operandi supports [Sorbet runtime types](https://sorbet.org/docs/runtime) for type validation. Sorbet types **only validate** and do not coerce values.
205
+
206
+ ### Arguments with Sorbet Types
207
+
208
+ ```ruby
209
+ require "sorbet-runtime"
210
+
211
+ class User::Create < ApplicationService
212
+ # Basic types
213
+ arg :name, type: String
214
+ arg :age, type: Integer
215
+
216
+ # Nilable types
217
+ arg :email, type: T.nilable(String), optional: true
218
+
219
+ # Union types
220
+ arg :status, type: T.any(String, Symbol)
221
+
222
+ # Typed arrays
223
+ arg :tags, type: T::Array[String], optional: true
224
+
225
+ # Boolean type
226
+ arg :active, type: T::Boolean, default: true
227
+ end
228
+ ```
229
+
230
+ ### Outputs with Sorbet Types
231
+
232
+ ```ruby
233
+ class AI::Chat < ApplicationService
234
+ # Typed array
235
+ output :messages, type: T::Array[Hash]
236
+
237
+ # Basic type
238
+ output :total_tokens, type: Integer
239
+
240
+ # Nilable type
241
+ output :metadata, type: T.nilable(Hash), optional: true
242
+ end
243
+ ```
244
+
245
+ ### Validation Behavior
246
+
247
+ Sorbet types do NOT coerce values - they only validate:
248
+
249
+ ```ruby
250
+ # This will RAISE an error (no coercion)
251
+ service = User::Create.run(name: "John", age: "25")
252
+ # => ArgTypeError: expected Integer, but got String
253
+
254
+ # This works correctly
255
+ service = User::Create.run(name: "John", age: 25)
256
+ service.age # => 25 (Integer)
257
+ ```
258
+
259
+ ### Common Sorbet Type Patterns
260
+
261
+ | Type | Description |
262
+ |------|-------------|
263
+ | `String` | Must be a String |
264
+ | `Integer` | Must be an Integer |
265
+ | `T.nilable(String)` | String or nil |
266
+ | `T.any(String, Symbol)` | String or Symbol |
267
+ | `T::Array[String]` | Array of Strings |
268
+ | `T::Hash[Symbol, String]` | Hash with Symbol keys and String values |
269
+ | `T::Boolean` | TrueClass or FalseClass |
@@ -0,0 +1,354 @@
1
+ ---
2
+ description: "Rules for writing RSpec tests for Operandi - testing arguments, steps, outputs, and behavior"
3
+ globs: "**/spec/services/*_spec.rb"
4
+ alwaysApply: false
5
+ ---
6
+
7
+ # Operandi RSpec Testing Rules
8
+
9
+ When writing RSpec tests for services that inherit from `Operandi::Base` or `ApplicationService`, follow these patterns.
10
+
11
+ ## Setup
12
+
13
+ Add to `spec/spec_helper.rb` or `spec/rails_helper.rb`:
14
+
15
+ ```ruby
16
+ require "operandi/rspec"
17
+ ```
18
+
19
+ ## Test Structure
20
+
21
+ Always structure test files in this order:
22
+ 1. Describe block with service class
23
+ 2. DSL definition tests (arguments, outputs, steps)
24
+ 3. Behavior tests (`.run` scenarios)
25
+ 4. Edge cases and error handling
26
+
27
+ ```ruby
28
+ # spec/services/model_name/action_name_spec.rb
29
+ RSpec.describe ModelName::ActionName do
30
+ # DSL Definition Tests
31
+ describe "arguments" do
32
+ it { expect(described_class).to define_argument(:required_param).with_type(String) }
33
+ it { expect(described_class).to define_argument(:optional_param).with_type(Integer).optional.with_default(0) }
34
+ end
35
+
36
+ describe "outputs" do
37
+ it { expect(described_class).to define_output(:result).with_type(Hash) }
38
+ end
39
+
40
+ describe "steps" do
41
+ it { expect(described_class).to define_steps_in_order(:validate, :perform, :cleanup) }
42
+ it { expect(described_class).to define_step(:cleanup).with_always(true) }
43
+ end
44
+
45
+ # Behavior Tests
46
+ describe ".run" do
47
+ subject(:service) { described_class.run(params) }
48
+ let(:params) { { required_param: "value" } }
49
+
50
+ context "when successful" do
51
+ it { is_expected.to be_successful }
52
+ it { expect(service.result).to eq(expected_result) }
53
+ end
54
+
55
+ context "when validation fails" do
56
+ let(:params) { { required_param: "" } }
57
+ it { is_expected.to be_failed }
58
+ it { is_expected.to have_error_on(:required_param) }
59
+ end
60
+ end
61
+ end
62
+ ```
63
+
64
+ ## Naming Conventions
65
+
66
+ - **File path**: `spec/services/model_name/action_name_spec.rb`
67
+ - **Describe block**: Use the service class name directly
68
+ - **Context blocks**: Start with "when" or "with" to describe conditions
69
+ - **It blocks**: Be specific about what is being tested
70
+
71
+ ## DSL Matchers
72
+
73
+ ### Arguments
74
+
75
+ ```ruby
76
+ # Basic argument
77
+ it { expect(described_class).to define_argument(:id) }
78
+
79
+ # With type (single or multiple)
80
+ it { expect(described_class).to define_argument(:id).with_type([String, Integer]) }
81
+
82
+ # Optional with default
83
+ it { expect(described_class).to define_argument(:status).optional.with_default("pending") }
84
+
85
+ # Context argument
86
+ it { expect(described_class).to define_argument(:current_user).with_context }
87
+ ```
88
+
89
+ ### Outputs
90
+
91
+ ```ruby
92
+ # Basic output
93
+ it { expect(described_class).to define_output(:user) }
94
+
95
+ # With type
96
+ it { expect(described_class).to define_output(:user).with_type(User) }
97
+
98
+ # Optional with default
99
+ it { expect(described_class).to define_output(:count).optional.with_default(0) }
100
+ ```
101
+
102
+ ### Steps
103
+
104
+ ```ruby
105
+ # Basic step
106
+ it { expect(described_class).to define_step(:validate) }
107
+
108
+ # With always flag
109
+ it { expect(described_class).to define_step(:cleanup).with_always(true) }
110
+
111
+ # Conditional steps
112
+ it { expect(described_class).to define_step(:notify).with_if(:should_notify?) }
113
+ it { expect(described_class).to define_step(:skip_audit).with_unless(:production?) }
114
+
115
+ # Multiple steps in order
116
+ it { expect(described_class).to define_steps_in_order(:validate, :process, :save) }
117
+ ```
118
+
119
+ ## Behavior Testing
120
+
121
+ ### Success Cases
122
+
123
+ ```ruby
124
+ describe ".run" do
125
+ subject(:service) { described_class.run(params) }
126
+ let(:params) { { name: "John", email: "john@example.com" } }
127
+
128
+ it { is_expected.to be_successful }
129
+ it { expect(service.user).to be_persisted }
130
+ it { expect(service.user.name).to eq("John") }
131
+ end
132
+ ```
133
+
134
+ ### Failure Cases
135
+
136
+ ```ruby
137
+ context "when validation fails" do
138
+ let(:params) { { name: "", email: "" } }
139
+
140
+ it { is_expected.to be_failed }
141
+ it { is_expected.to have_error_on(:name) }
142
+ it { is_expected.to have_error_on(:email).with_message("can't be blank") }
143
+ it { is_expected.to have_errors_on(:name, :email) }
144
+ end
145
+ ```
146
+
147
+ ### Warnings
148
+
149
+ ```ruby
150
+ it { expect(service.warnings?).to be true }
151
+ it { is_expected.to have_warning_on(:format).with_message("format is deprecated") }
152
+ ```
153
+
154
+ ### run! vs run
155
+
156
+ ```ruby
157
+ # .run returns failed service
158
+ it { expect(described_class.run(amount: -100)).to be_failed }
159
+
160
+ # .run! raises exception
161
+ it { expect { described_class.run!(amount: -100) }.to raise_error(Operandi::Error, /must be positive/) }
162
+ ```
163
+
164
+ ### Config Overrides
165
+
166
+ ```ruby
167
+ # With raise_on_error
168
+ expect { described_class.run({ invalid: true }, { raise_on_error: true }) }
169
+ .to raise_error(Operandi::Error)
170
+
171
+ # With use_transactions: false
172
+ service = described_class.with(use_transactions: false).run(params)
173
+ ```
174
+
175
+ ## Database Testing
176
+
177
+ ```ruby
178
+ # Record creation
179
+ expect { described_class.run(params) }.to change(User, :count).by(1)
180
+ expect(service.user).to be_persisted
181
+
182
+ # Transaction rollback on failure
183
+ allow_any_instance_of(ChildService).to receive(:perform) do |svc|
184
+ svc.errors.add(:base, "Simulated failure")
185
+ end
186
+ expect { described_class.run(params) }.not_to change(Order, :count)
187
+ ```
188
+
189
+ ## Service Chaining
190
+
191
+ ```ruby
192
+ # Verify context sharing
193
+ expect(ChildService).to receive(:with).and_call_original
194
+ described_class.run(current_user: current_user, data: data)
195
+
196
+ # Error propagation from child
197
+ allow_any_instance_of(ChildService).to receive(:validate) { |svc| svc.fail!("Child error") }
198
+ expect(described_class.run(current_user: current_user, data: data))
199
+ .to have_error_on(:base).with_message("Child error")
200
+ ```
201
+
202
+ ## Conditional Steps
203
+
204
+ ```ruby
205
+ # When condition is true
206
+ expect { described_class.run(send_notification: true, email: "x@example.com") }
207
+ .to have_enqueued_mail(UserMailer, :notification)
208
+
209
+ # When condition is false
210
+ expect { described_class.run(send_notification: false, email: "x@example.com") }
211
+ .not_to have_enqueued_mail(UserMailer, :notification)
212
+ ```
213
+
214
+ ## Early Exit (stop!)
215
+
216
+ ```ruby
217
+ existing_user = create(:user, email: "exists@example.com")
218
+ service = described_class.run(email: existing_user.email)
219
+
220
+ expect { service }.not_to change(User, :count)
221
+ expect(service.user).to eq(existing_user)
222
+ expect(service).to be_successful
223
+ expect(service.stopped?).to be true
224
+ ```
225
+
226
+ ## Argument Validation
227
+
228
+ ```ruby
229
+ # Required argument nil
230
+ expect { described_class.run(name: nil) }.to raise_error(Operandi::ArgTypeError)
231
+
232
+ # Wrong type
233
+ expect { described_class.run(name: 123) }.to raise_error(Operandi::ArgTypeError, /must be a String/)
234
+
235
+ # Optional accepts nil
236
+ expect(described_class.run(name: "John", nickname: nil)).to be_successful
237
+
238
+ # Default values
239
+ expect(described_class.run(name: "John").status).to eq("pending")
240
+ ```
241
+
242
+ ## External Services
243
+
244
+ ```ruby
245
+ let(:stripe_client) { instance_double(Stripe::PaymentIntent, id: "pi_123") }
246
+
247
+ before { allow(Stripe::PaymentIntent).to receive(:create).and_return(stripe_client) }
248
+
249
+ it "processes payment" do
250
+ service = described_class.run(amount: 1000, card_token: "tok_visa")
251
+ expect(service).to be_successful
252
+ expect(service.payment_intent_id).to eq("pi_123")
253
+ end
254
+
255
+ context "when external fails" do
256
+ before { allow(Stripe::PaymentIntent).to receive(:create).and_raise(Stripe::CardError.new("Card declined", nil, nil)) }
257
+
258
+ it { expect(service).to be_failed }
259
+ it { expect(service).to have_error_on(:payment).with_message("Card declined") }
260
+ end
261
+ ```
262
+
263
+ ## Optional Tracking
264
+
265
+ ### Step Execution
266
+
267
+ ```ruby
268
+ # app/services/application_service.rb
269
+ class ApplicationService < Operandi::Base
270
+ output :executed_steps, type: Array, default: -> { [] }
271
+ after_step_run { |service, step| service.executed_steps << step }
272
+ end
273
+ ```
274
+
275
+ ### Callback Tracking
276
+
277
+ ```ruby
278
+ class ApplicationService < Operandi::Base
279
+ output :callback_log, type: Array, default: -> { [] }
280
+ before_service_run { |s| s.callback_log << :before_service_run }
281
+ after_service_run { |s| s.callback_log << :after_service_run }
282
+ on_service_success { |s| s.callback_log << :on_service_success }
283
+ on_service_failure { |s| s.callback_log << :on_service_failure }
284
+ end
285
+ ```
286
+
287
+ ## Shared Examples
288
+
289
+ ### Create Service
290
+
291
+ ```ruby
292
+ RSpec.shared_examples "a create service" do |model_class|
293
+ let(:valid_attributes) { attributes_for(model_class.name.underscore.to_sym) }
294
+ let(:current_user) { create(:user) }
295
+
296
+ it { expect { described_class.run(current_user: current_user, attributes: valid_attributes) }.to change(model_class, :count).by(1) }
297
+ it { expect(described_class.run(current_user: current_user, attributes: valid_attributes).record).to be_persisted }
298
+ it { expect(described_class.run(current_user: current_user, attributes: valid_attributes)).to be_successful }
299
+ end
300
+ ```
301
+
302
+ ### Authorized Service
303
+
304
+ ```ruby
305
+ RSpec.shared_examples "an authorized service" do
306
+ context "without current_user" do
307
+ let(:current_user) { nil }
308
+ it { is_expected.to be_failed }
309
+ it { is_expected.to have_error_on(:authorization) }
310
+ end
311
+
312
+ context "with unauthorized user" do
313
+ let(:current_user) { create(:user, role: :guest) }
314
+ it { is_expected.to be_failed }
315
+ it { is_expected.to have_error_on(:authorization) }
316
+ end
317
+ end
318
+ ```
319
+
320
+ ## Test Helpers
321
+
322
+ ```ruby
323
+ module ServiceHelpers
324
+ def expect_service_success(service)
325
+ expect(service).to be_successful, -> { "Expected success but got errors: #{service.errors.to_h}" }
326
+ end
327
+
328
+ def expect_service_failure(service, key = nil)
329
+ expect(service).to be_failed
330
+ expect(service.errors[key]).to be_present if key
331
+ end
332
+ end
333
+
334
+ RSpec.configure { |config| config.include ServiceHelpers, type: :service }
335
+ ```
336
+
337
+ ## Common Matchers Reference
338
+
339
+ | Matcher | Description |
340
+ |---------|-------------|
341
+ | `be_successful` | Service completed without errors |
342
+ | `be_failed` | Service has errors |
343
+ | `have_error_on(:key)` | Has error on specific key |
344
+ | `have_error_on(:key).with_message(msg)` | Error with specific message |
345
+ | `have_errors_on(:key1, :key2)` | Has errors on multiple keys |
346
+ | `have_warning_on(:key)` | Has warning on specific key |
347
+ | `define_argument(:name)` | Service defines argument |
348
+ | `define_output(:name)` | Service defines output |
349
+ | `define_step(:name)` | Service defines step |
350
+ | `define_steps(:a, :b, :c)` | Service defines all steps (any order) |
351
+ | `define_steps_in_order(:a, :b, :c)` | Service defines steps in order |
352
+ | `execute_step(:name)` | Step was executed (tracking required) |
353
+ | `skip_step(:name)` | Step was skipped (tracking required) |
354
+ | `trigger_callback(:name)` | Callback was triggered (tracking required) |
@@ -0,0 +1,11 @@
1
+ # To get started with Dependabot version updates, you'll need to specify which
2
+ # package ecosystems to update and where the package manifests are located.
3
+ # Please see the documentation for all configuration options:
4
+ # https://docs.github.com/code-security/dependabot/dependabot-version-updates/configuration-options-for-the-dependabot.yml-file
5
+
6
+ version: 2
7
+ updates:
8
+ - package-ecosystem: "bundler"
9
+ directory: "/"
10
+ schedule:
11
+ interval: "weekly"
@@ -0,0 +1,77 @@
1
+ name: CI
2
+
3
+ on:
4
+ push:
5
+ branches: [main]
6
+ pull_request:
7
+ branches: [main]
8
+
9
+ concurrency:
10
+ group: ${{ github.workflow }}-${{ github.ref }}
11
+ cancel-in-progress: true
12
+
13
+ jobs:
14
+ rubocop:
15
+ name: RuboCop
16
+ runs-on: ubuntu-latest
17
+ steps:
18
+ - name: Checkout
19
+ uses: actions/checkout@v4
20
+
21
+ - name: Setup Ruby
22
+ uses: ruby/setup-ruby@v1
23
+ with:
24
+ ruby-version: "3.4"
25
+ bundler-cache: true
26
+
27
+ - name: Run RuboCop
28
+ run: bundle exec rubocop
29
+
30
+ rspec:
31
+ name: RSpec (Ruby ${{ matrix.ruby }})
32
+ runs-on: ubuntu-latest
33
+ strategy:
34
+ fail-fast: false
35
+ matrix:
36
+ ruby: ["3.1", "3.2", "3.3", "3.4"]
37
+ steps:
38
+ - name: Checkout
39
+ uses: actions/checkout@v4
40
+
41
+ - name: Install SQLite
42
+ run: sudo apt-get install -y libsqlite3-dev
43
+
44
+ - name: Setup Ruby
45
+ uses: ruby/setup-ruby@v1
46
+ with:
47
+ ruby-version: ${{ matrix.ruby }}
48
+ bundler-cache: true
49
+
50
+ - name: Run RSpec
51
+ run: bundle exec rspec
52
+
53
+ codecov:
54
+ name: Code Coverage
55
+ runs-on: ubuntu-latest
56
+ steps:
57
+ - name: Checkout
58
+ uses: actions/checkout@v4
59
+
60
+ - name: Install SQLite
61
+ run: sudo apt-get install -y libsqlite3-dev
62
+
63
+ - name: Setup Ruby
64
+ uses: ruby/setup-ruby@v1
65
+ with:
66
+ ruby-version: "3.4"
67
+ bundler-cache: true
68
+
69
+ - name: Run RSpec
70
+ run: bundle exec rspec
71
+
72
+ - name: Upload coverage to Codecov
73
+ uses: codecov/codecov-action@v5
74
+ with:
75
+ token: ${{ secrets.CODECOV_TOKEN }}
76
+ files: coverage/coverage.xml
77
+ fail_ci_if_error: true