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.
- checksums.yaml +7 -0
- data/.cursor/rules/services/RULE.md +269 -0
- data/.cursor/rules/services-rspec/RULE.md +354 -0
- data/.github/dependabot.yml +11 -0
- data/.github/workflows/ci.yml +77 -0
- data/.gitignore +25 -0
- data/.rspec +3 -0
- data/.rubocop.yml +134 -0
- data/.ruby-version +1 -0
- data/.vscode/cspell.json +18 -0
- data/.vscode/project-words.txt +12 -0
- data/AGENTS.md +139 -0
- data/CHANGELOG.md +111 -0
- data/CLAUDE.md +139 -0
- data/CODE_OF_CONDUCT.md +74 -0
- data/Gemfile +28 -0
- data/Gemfile.lock +149 -0
- data/LICENSE.txt +21 -0
- data/README.md +172 -0
- data/Rakefile +8 -0
- data/bin/console +15 -0
- data/bin/setup +8 -0
- data/config/default.yml +57 -0
- data/docs/README.md +105 -0
- data/docs/SUMMARY.md +31 -0
- data/docs/arguments.md +275 -0
- data/docs/best-practices.md +153 -0
- data/docs/callbacks.md +475 -0
- data/docs/concepts.md +79 -0
- data/docs/configuration.md +218 -0
- data/docs/context.md +128 -0
- data/docs/crud.md +525 -0
- data/docs/errors.md +331 -0
- data/docs/generators.md +250 -0
- data/docs/outputs.md +150 -0
- data/docs/pundit-authorization.md +320 -0
- data/docs/quickstart.md +133 -0
- data/docs/recipes.md +14 -0
- data/docs/rubocop.md +430 -0
- data/docs/ruby-lsp.md +121 -0
- data/docs/service-rendering.md +222 -0
- data/docs/sorbet-runtime.md +283 -0
- data/docs/steps.md +438 -0
- data/docs/tapioca.md +188 -0
- data/docs/testing.md +548 -0
- data/lib/generators/operandi/install/USAGE +15 -0
- data/lib/generators/operandi/install/install_generator.rb +45 -0
- data/lib/generators/operandi/install/templates/application_service.rb.tt +8 -0
- data/lib/generators/operandi/install/templates/application_service_spec.rb.tt +7 -0
- data/lib/generators/operandi/install/templates/initializer.rb.tt +30 -0
- data/lib/generators/operandi/service/USAGE +21 -0
- data/lib/generators/operandi/service/service_generator.rb +80 -0
- data/lib/generators/operandi/service/templates/service.rb.tt +48 -0
- data/lib/generators/operandi/service/templates/service_spec.rb.tt +40 -0
- data/lib/operandi/base.rb +230 -0
- data/lib/operandi/base_with_context.rb +57 -0
- data/lib/operandi/callbacks.rb +353 -0
- data/lib/operandi/collection.rb +166 -0
- data/lib/operandi/concerns/execution.rb +80 -0
- data/lib/operandi/concerns/parent_service.rb +32 -0
- data/lib/operandi/concerns/state_management.rb +34 -0
- data/lib/operandi/config.rb +142 -0
- data/lib/operandi/constants.rb +96 -0
- data/lib/operandi/dsl/arguments_dsl.rb +83 -0
- data/lib/operandi/dsl/outputs_dsl.rb +79 -0
- data/lib/operandi/dsl/steps_dsl.rb +206 -0
- data/lib/operandi/dsl/validation.rb +171 -0
- data/lib/operandi/exceptions.rb +66 -0
- data/lib/operandi/message.rb +52 -0
- data/lib/operandi/messages.rb +185 -0
- data/lib/operandi/rspec/matchers/define_argument.rb +172 -0
- data/lib/operandi/rspec/matchers/define_output.rb +145 -0
- data/lib/operandi/rspec/matchers/define_step.rb +223 -0
- data/lib/operandi/rspec/matchers/execute_step.rb +228 -0
- data/lib/operandi/rspec/matchers/have_error_on.rb +144 -0
- data/lib/operandi/rspec/matchers/have_warning_on.rb +146 -0
- data/lib/operandi/rspec/matchers/trigger_callback.rb +136 -0
- data/lib/operandi/rspec.rb +15 -0
- data/lib/operandi/rubocop/cop/operandi/argument_type_required.rb +52 -0
- data/lib/operandi/rubocop/cop/operandi/condition_method_exists.rb +173 -0
- data/lib/operandi/rubocop/cop/operandi/deprecated_accessors.rb +113 -0
- data/lib/operandi/rubocop/cop/operandi/deprecated_methods.rb +113 -0
- data/lib/operandi/rubocop/cop/operandi/dsl_order.rb +181 -0
- data/lib/operandi/rubocop/cop/operandi/missing_private_keyword.rb +102 -0
- data/lib/operandi/rubocop/cop/operandi/no_direct_instantiation.rb +66 -0
- data/lib/operandi/rubocop/cop/operandi/no_hash_argument.rb +101 -0
- data/lib/operandi/rubocop/cop/operandi/output_type_required.rb +52 -0
- data/lib/operandi/rubocop/cop/operandi/prefer_fail_method.rb +112 -0
- data/lib/operandi/rubocop/cop/operandi/prefer_optional_over_default_nil.rb +124 -0
- data/lib/operandi/rubocop/cop/operandi/redundant_optional.rb +103 -0
- data/lib/operandi/rubocop/cop/operandi/reserved_name.rb +56 -0
- data/lib/operandi/rubocop/cop/operandi/step_method_exists.rb +134 -0
- data/lib/operandi/rubocop.rb +22 -0
- data/lib/operandi/settings/field.rb +147 -0
- data/lib/operandi/settings/step.rb +105 -0
- data/lib/operandi/utils.rb +36 -0
- data/lib/operandi/version.rb +5 -0
- data/lib/operandi.rb +11 -0
- data/lib/ruby_lsp/operandi/addon.rb +37 -0
- data/lib/ruby_lsp/operandi/definition.rb +134 -0
- data/lib/ruby_lsp/operandi/indexing_enhancement.rb +224 -0
- data/lib/tapioca/dsl/compilers/operandi.rb +378 -0
- data/operandi.gemspec +33 -0
- data/rbi/operandi.rbi +197 -0
- data/sorbet/cache/data.mdb +0 -0
- data/sorbet/cache/lock.mdb +0 -0
- 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
|