servus 0.3.0 → 0.4.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 (132) hide show
  1. checksums.yaml +4 -4
  2. data/lib/generators/servus/event_handler/event_handler_generator.rb +1 -1
  3. data/lib/generators/servus/guard/guard_generator.rb +1 -1
  4. data/lib/generators/servus/guard/templates/guard.rb.erb +5 -3
  5. data/lib/generators/servus/service/service_generator.rb +1 -1
  6. data/lib/servus/base.rb +46 -3
  7. data/lib/servus/config.rb +71 -3
  8. data/lib/servus/events/bus.rb +29 -0
  9. data/lib/servus/events/emitter.rb +15 -0
  10. data/lib/servus/guard.rb +7 -6
  11. data/lib/servus/guards/falsey_guard.rb +3 -3
  12. data/lib/servus/guards/presence_guard.rb +4 -4
  13. data/lib/servus/guards/state_guard.rb +4 -5
  14. data/lib/servus/guards/truthy_guard.rb +3 -3
  15. data/lib/servus/helpers/controller_helpers.rb +40 -0
  16. data/lib/servus/support/errors.rb +16 -0
  17. data/lib/servus/support/lockdown.rb +94 -0
  18. data/lib/servus/support/logger.rb +16 -0
  19. data/lib/servus/support/validator.rb +65 -34
  20. data/lib/servus/testing/example_builders.rb +52 -0
  21. data/lib/servus/testing/matchers.rb +99 -0
  22. data/lib/servus/version.rb +1 -1
  23. data/lib/servus.rb +1 -0
  24. metadata +7 -111
  25. data/.claude/commands/check-docs.md +0 -1
  26. data/.claude/commands/consistency-check.md +0 -1
  27. data/.claude/commands/fine-tooth-comb.md +0 -1
  28. data/.claude/commands/red-green-refactor.md +0 -5
  29. data/.claude/settings.json +0 -24
  30. data/.rspec +0 -3
  31. data/.rubocop.yml +0 -27
  32. data/.yardopts +0 -6
  33. data/CHANGELOG.md +0 -169
  34. data/CLAUDE.md +0 -10
  35. data/IDEAS.md +0 -5
  36. data/LICENSE.txt +0 -21
  37. data/READme.md +0 -856
  38. data/Rakefile +0 -45
  39. data/docs/core/1_overview.md +0 -81
  40. data/docs/core/2_architecture.md +0 -120
  41. data/docs/core/3_service_objects.md +0 -154
  42. data/docs/features/1_schema_validation.md +0 -161
  43. data/docs/features/2_error_handling.md +0 -129
  44. data/docs/features/3_async_execution.md +0 -81
  45. data/docs/features/4_logging.md +0 -64
  46. data/docs/features/5_event_bus.md +0 -244
  47. data/docs/features/6_guards.md +0 -356
  48. data/docs/features/7_lazy_resolvers.md +0 -238
  49. data/docs/features/guards_naming_convention.md +0 -540
  50. data/docs/guides/1_common_patterns.md +0 -90
  51. data/docs/guides/2_migration_guide.md +0 -225
  52. data/docs/integration/1_configuration.md +0 -154
  53. data/docs/integration/2_testing.md +0 -304
  54. data/docs/integration/3_rails_integration.md +0 -99
  55. data/docs/yard/Servus/Base.html +0 -1645
  56. data/docs/yard/Servus/Config.html +0 -582
  57. data/docs/yard/Servus/Extensions/Async/Call.html +0 -400
  58. data/docs/yard/Servus/Extensions/Async/Errors/AsyncError.html +0 -140
  59. data/docs/yard/Servus/Extensions/Async/Errors/JobEnqueueError.html +0 -154
  60. data/docs/yard/Servus/Extensions/Async/Errors/ServiceNotFoundError.html +0 -154
  61. data/docs/yard/Servus/Extensions/Async/Errors.html +0 -128
  62. data/docs/yard/Servus/Extensions/Async/Ext.html +0 -119
  63. data/docs/yard/Servus/Extensions/Async/Job.html +0 -310
  64. data/docs/yard/Servus/Extensions/Async.html +0 -141
  65. data/docs/yard/Servus/Extensions.html +0 -117
  66. data/docs/yard/Servus/Generators/ServiceGenerator.html +0 -261
  67. data/docs/yard/Servus/Generators.html +0 -115
  68. data/docs/yard/Servus/Helpers/ControllerHelpers.html +0 -457
  69. data/docs/yard/Servus/Helpers.html +0 -115
  70. data/docs/yard/Servus/Railtie.html +0 -134
  71. data/docs/yard/Servus/Support/Errors/AuthenticationError.html +0 -287
  72. data/docs/yard/Servus/Support/Errors/BadRequestError.html +0 -283
  73. data/docs/yard/Servus/Support/Errors/ForbiddenError.html +0 -284
  74. data/docs/yard/Servus/Support/Errors/InternalServerError.html +0 -283
  75. data/docs/yard/Servus/Support/Errors/NotFoundError.html +0 -284
  76. data/docs/yard/Servus/Support/Errors/ServiceError.html +0 -489
  77. data/docs/yard/Servus/Support/Errors/ServiceUnavailableError.html +0 -290
  78. data/docs/yard/Servus/Support/Errors/UnauthorizedError.html +0 -200
  79. data/docs/yard/Servus/Support/Errors/UnprocessableEntityError.html +0 -288
  80. data/docs/yard/Servus/Support/Errors/ValidationError.html +0 -200
  81. data/docs/yard/Servus/Support/Errors.html +0 -140
  82. data/docs/yard/Servus/Support/Logger.html +0 -856
  83. data/docs/yard/Servus/Support/Rescuer/BlockContext.html +0 -585
  84. data/docs/yard/Servus/Support/Rescuer/CallOverride.html +0 -257
  85. data/docs/yard/Servus/Support/Rescuer/ClassMethods.html +0 -343
  86. data/docs/yard/Servus/Support/Rescuer.html +0 -267
  87. data/docs/yard/Servus/Support/Response.html +0 -574
  88. data/docs/yard/Servus/Support/Validator.html +0 -1150
  89. data/docs/yard/Servus/Support.html +0 -119
  90. data/docs/yard/Servus/Testing/ExampleBuilders.html +0 -523
  91. data/docs/yard/Servus/Testing/ExampleExtractor.html +0 -578
  92. data/docs/yard/Servus/Testing.html +0 -142
  93. data/docs/yard/Servus.html +0 -343
  94. data/docs/yard/_index.html +0 -535
  95. data/docs/yard/class_list.html +0 -54
  96. data/docs/yard/css/common.css +0 -1
  97. data/docs/yard/css/full_list.css +0 -58
  98. data/docs/yard/css/style.css +0 -503
  99. data/docs/yard/file.1_common_patterns.html +0 -154
  100. data/docs/yard/file.1_configuration.html +0 -115
  101. data/docs/yard/file.1_overview.html +0 -142
  102. data/docs/yard/file.1_schema_validation.html +0 -188
  103. data/docs/yard/file.2_architecture.html +0 -157
  104. data/docs/yard/file.2_error_handling.html +0 -190
  105. data/docs/yard/file.2_migration_guide.html +0 -242
  106. data/docs/yard/file.2_testing.html +0 -227
  107. data/docs/yard/file.3_async_execution.html +0 -145
  108. data/docs/yard/file.3_rails_integration.html +0 -160
  109. data/docs/yard/file.3_service_objects.html +0 -191
  110. data/docs/yard/file.4_logging.html +0 -135
  111. data/docs/yard/file.ErrorHandling.html +0 -190
  112. data/docs/yard/file.READme.html +0 -674
  113. data/docs/yard/file.architecture.html +0 -157
  114. data/docs/yard/file.async_execution.html +0 -145
  115. data/docs/yard/file.common_patterns.html +0 -154
  116. data/docs/yard/file.configuration.html +0 -115
  117. data/docs/yard/file.error_handling.html +0 -190
  118. data/docs/yard/file.logging.html +0 -135
  119. data/docs/yard/file.migration_guide.html +0 -242
  120. data/docs/yard/file.overview.html +0 -142
  121. data/docs/yard/file.rails_integration.html +0 -160
  122. data/docs/yard/file.schema_validation.html +0 -188
  123. data/docs/yard/file.service_objects.html +0 -191
  124. data/docs/yard/file.testing.html +0 -227
  125. data/docs/yard/file_list.html +0 -119
  126. data/docs/yard/frames.html +0 -22
  127. data/docs/yard/index.html +0 -674
  128. data/docs/yard/js/app.js +0 -344
  129. data/docs/yard/js/full_list.js +0 -242
  130. data/docs/yard/js/jquery.js +0 -4
  131. data/docs/yard/method_list.html +0 -542
  132. data/docs/yard/top-level-namespace.html +0 -110
@@ -1,225 +0,0 @@
1
- # @title Guides / 2. Migration Guide
2
-
3
- # Migration Guide
4
-
5
- Strategies for adopting Servus in existing Rails applications, and version-specific migration notes.
6
-
7
- ## Migrating to 0.3.0
8
-
9
- ### Breaking: `result.data` is no longer guaranteed nil on failure
10
-
11
- In 0.2.x, `failure()` always set `result.data` to `nil`. Some code relied on this to distinguish success from failure:
12
-
13
- ```ruby
14
- # ❌ Unsafe in 0.3.0 — result.data can be non-nil on failure
15
- if result.data
16
- # handle success
17
- else
18
- # handle failure
19
- end
20
- ```
21
-
22
- In 0.3.0, `failure()` accepts an optional `data:` kwarg, so failure responses can carry structured data. Use `result.success?` or `result.failure?` instead:
23
-
24
- ```ruby
25
- # ✅ Correct — always use success? or failure?
26
- if result.success?
27
- render json: result.data, status: :ok
28
- else
29
- render json: { error: result.error.api_error, details: result.data }, status: result.error.http_status
30
- end
31
- ```
32
-
33
- **How to find affected code**: Search your codebase for patterns like `if result.data`, `result.data.present?`, or `result.data.nil?` used as success/failure checks. Replace them with `result.success?` or `result.failure?`.
34
-
35
- ### Removed: `Response#with_data`
36
-
37
- `with_data` was removed in favor of the `data:` kwarg on `failure()`:
38
-
39
- ```ruby
40
- # ❌ 0.2.x — no longer available
41
- failure("Declined").tap { |r| r.with_data(reason: "insufficient_funds") }
42
-
43
- # ✅ 0.3.0
44
- failure("Declined", data: { reason: "insufficient_funds" })
45
- ```
46
-
47
- ### New: DataObject wrapping
48
-
49
- Response data is now wrapped in a `DataObject` when it is a Hash. This is backwards compatible — `result.data[:key]` still works. You can also use accessor-style access:
50
-
51
- ```ruby
52
- result.data[:user] # still works
53
- result.data.user # also works (new)
54
- result.data.user.email # nested access (new)
55
- ```
56
-
57
- ## Incremental Adoption
58
-
59
- Servus coexists with existing code - no need to rewrite your entire application. Start with one complex use case, validate the pattern works for your team, then expand gradually.
60
-
61
- ## Extracting from Fat Controllers
62
-
63
- Identify controller actions with complex business logic and extract to services:
64
-
65
- **Before**:
66
- ```ruby
67
- class OrdersController < ApplicationController
68
- def create
69
- # 50 lines of business logic
70
- # Multiple model operations
71
- # External API calls
72
- # Email sending
73
- end
74
- end
75
- ```
76
-
77
- **After**:
78
- ```ruby
79
- class OrdersController < ApplicationController
80
- def create
81
- result = Orders::Create::Service.call(order_params)
82
- if result.success?
83
- render json: { order: result.data[:order] }, status: :created
84
- else
85
- render json: { error: result.error.api_error }, status: :unprocessable_entity
86
- end
87
- end
88
- end
89
-
90
- # Or use the helper
91
- class OrdersController < ApplicationController
92
- include Servus::Helpers::ControllerHelpers
93
-
94
- def create
95
- run_service(Orders::Create::Service, order_params) do |result|
96
- render json: { order: result.data[:order] }, status: :created
97
- end
98
- end
99
- end
100
- ```
101
-
102
- ## Extracting from Fat Models
103
-
104
- Move orchestration logic from models to services. Keep data-related methods in models:
105
-
106
- **Before**:
107
- ```ruby
108
- class Order < ApplicationRecord
109
- def complete_purchase
110
- charge_payment
111
- update_inventory
112
- send_confirmation_email
113
- create_invoice
114
- end
115
- end
116
- ```
117
-
118
- **After**:
119
- ```ruby
120
- class Order < ApplicationRecord
121
- # Model focuses on data
122
- validates :total, presence: true
123
- belongs_to :user
124
- end
125
-
126
- class Orders::CompletePurchase::Service < Servus::Base
127
- # Service handles orchestration
128
- def initialize(order_id:)
129
- @order_id = order_id
130
- end
131
-
132
- def call
133
- order = Order.find(@order_id)
134
- Payments::Charge::Service.call(order_id: order.id)
135
- Inventory::Update::Service.call(order_id: order.id)
136
- Mailers::SendConfirmation::Service.call(order_id: order.id)
137
- success(order: order)
138
- end
139
- end
140
- ```
141
-
142
- ## Replacing Callbacks
143
-
144
- Extract callback logic to explicit service calls:
145
-
146
- **Before**:
147
- ```ruby
148
- class User < ApplicationRecord
149
- after_create :send_welcome_email
150
- after_create :create_default_account
151
- after_update :notify_changes, if: :email_changed?
152
- end
153
- ```
154
-
155
- **After**:
156
- ```ruby
157
- class User < ApplicationRecord
158
- # Minimal or no callbacks
159
- end
160
-
161
- class Users::Create::Service < Servus::Base
162
- def call
163
- user = User.create!(params)
164
- send_welcome_email(user)
165
- create_default_account(user)
166
- success(user: user)
167
- end
168
- end
169
- ```
170
-
171
- ## Migrating Background Jobs
172
-
173
- Extract job logic to services, call via `.call_async`:
174
-
175
- **Before**:
176
- ```ruby
177
- class ProcessOrderJob < ApplicationJob
178
- def perform(order_id)
179
- # 50 lines of business logic
180
- end
181
- end
182
-
183
- ProcessOrderJob.perform_later(order.id)
184
- ```
185
-
186
- **After**:
187
- ```ruby
188
- class Orders::Process::Service < Servus::Base
189
- def initialize(order_id:)
190
- @order_id = order_id
191
- end
192
-
193
- def call
194
- # Business logic
195
- end
196
- end
197
-
198
- Orders::Process::Service.call_async(order_id: order.id)
199
- ```
200
-
201
- Now the service can be called synchronously (from console, tests) or asynchronously (from controllers, jobs).
202
-
203
- ## Testing During Migration
204
-
205
- Keep existing tests working while adding service tests:
206
-
207
- ```ruby
208
- # Keep existing controller test
209
- describe OrdersController do
210
- it "creates order" do
211
- post :create, params: params
212
- expect(response).to be_successful
213
- end
214
- end
215
-
216
- # Add service test
217
- describe Orders::Create::Service do
218
- it "creates order" do
219
- result = described_class.call(params)
220
- expect(result.success?).to be true
221
- end
222
- end
223
- ```
224
-
225
- Remove legacy tests after service tests prove comprehensive.
@@ -1,154 +0,0 @@
1
- # @title Integration / 1. Configuration
2
-
3
- # Configuration
4
-
5
- Servus works without configuration. Optional settings exist for customizing directories and event validation.
6
-
7
- ## Directory Configuration
8
-
9
- Configure where Servus looks for schemas, services, event handlers, and guards:
10
-
11
- ```ruby
12
- # config/initializers/servus.rb
13
- Servus.configure do |config|
14
- # Default: 'app/schemas'
15
- config.schemas_dir = 'app/schemas'
16
-
17
- # Default: 'app/services'
18
- config.services_dir = 'app/services'
19
-
20
- # Default: 'app/events'
21
- config.events_dir = 'app/events'
22
-
23
- # Default: 'app/guards'
24
- config.guards_dir = 'app/guards'
25
- end
26
- ```
27
-
28
- These affect legacy file-based schemas, handler auto-loading, and guard auto-loading. Schemas defined via the `schema` DSL method do not use files.
29
-
30
- ## Schema Cache
31
-
32
- Schemas are cached after first load for performance. Clear the cache during development when schemas change:
33
-
34
- ```ruby
35
- Servus::Support::Validator.clear_cache!
36
- ```
37
-
38
- In production, schemas are deployed with code - no need to clear cache.
39
-
40
- ## Log Level
41
-
42
- Servus uses `Rails.logger` (or stdout in non-Rails apps). Control logging via Rails configuration:
43
-
44
- ```ruby
45
- # config/environments/production.rb
46
- config.log_level = :info # Hides DEBUG argument logs
47
- ```
48
-
49
- ## ActiveJob Configuration
50
-
51
- Async execution uses ActiveJob. Configure your adapter:
52
-
53
- ```ruby
54
- # config/application.rb
55
- config.active_job.queue_adapter = :sidekiq
56
- config.active_job.default_queue_name = :default
57
- ```
58
-
59
- Servus respects ActiveJob queue configuration - no Servus-specific setup needed.
60
-
61
- ## Guards Configuration
62
-
63
- ### Default Guards
64
-
65
- Servus includes built-in guards (`PresenceGuard`, `TruthyGuard`, `FalseyGuard`, `StateGuard`) that are loaded by default. Disable them if you want to define your own:
66
-
67
- ```ruby
68
- # config/initializers/servus.rb
69
- Servus.configure do |config|
70
- # Default: true
71
- config.include_default_guards = false
72
- end
73
- ```
74
-
75
- ### Guard Auto-Loading
76
-
77
- In Rails, custom guards in `app/guards/` are automatically loaded. The Railtie eager-loads all `*_guard.rb` files from `config.guards_dir`:
78
-
79
- ```
80
- app/guards/
81
- ├── sufficient_balance_guard.rb
82
- ├── valid_amount_guard.rb
83
- └── authorized_guard.rb
84
- ```
85
-
86
- Guards define methods on `Servus::Guards` when inherited from `Servus::Guard`. The `Guard` suffix is stripped from the method name:
87
-
88
- ```ruby
89
- # app/guards/sufficient_balance_guard.rb
90
- class SufficientBalanceGuard < Servus::Guard
91
- http_status 422
92
- error_code 'insufficient_balance'
93
-
94
- message 'Insufficient balance: need %<required>s, have %<available>s' do
95
- { required: amount, available: account.balance }
96
- end
97
-
98
- def test(account:, amount:)
99
- account.balance >= amount
100
- end
101
- end
102
-
103
- # Usage in services:
104
- # enforce_sufficient_balance!(account: account, amount: 100) # throws on failure
105
- # check_sufficient_balance?(account: account, amount: 100) # returns boolean
106
- ```
107
-
108
- ## Event Bus Configuration
109
-
110
- ### Strict Event Validation
111
-
112
- Enable strict validation to catch handlers subscribing to events that aren't emitted by any service:
113
-
114
- ```ruby
115
- # config/initializers/servus.rb
116
- Servus.configure do |config|
117
- # Default: true
118
- config.strict_event_validation = true
119
- end
120
- ```
121
-
122
- When enabled, you can validate handlers at boot or in CI:
123
-
124
- ```ruby
125
- # In a rake task or initializer
126
- Servus::EventHandler.validate_all_handlers!
127
- ```
128
-
129
- This raises `Servus::Events::OrphanedHandlerError` if any handler subscribes to a non-existent event.
130
-
131
- ### Handler Auto-Loading
132
-
133
- In Rails, handlers in `app/events/` are automatically loaded. The Railtie:
134
- - Clears the event bus on reload in development
135
- - Eager-loads all `*_handler.rb` files from `config.events_dir`
136
-
137
- ```
138
- app/events/
139
- ├── user_created_handler.rb
140
- ├── payment_processed_handler.rb
141
- └── order_completed_handler.rb
142
- ```
143
-
144
- ### Event Instrumentation
145
-
146
- Events are instrumented via ActiveSupport::Notifications with the prefix `servus.events.`:
147
-
148
- ```ruby
149
- # Subscribe to all Servus events
150
- ActiveSupport::Notifications.subscribe(/^servus\.events\./) do |name, *args|
151
- event_name = name.sub('servus.events.', '')
152
- Rails.logger.info "Event: #{event_name}"
153
- end
154
- ```
@@ -1,304 +0,0 @@
1
- # @title Integration / 2. Testing
2
-
3
- # Testing
4
-
5
- Services are designed for easy testing with explicit inputs (arguments) and outputs (Response objects). No special test infrastructure needed.
6
-
7
- ## Schema Example Helpers
8
-
9
- Servus provides test helpers that extract `example` values from your JSON schemas, making it easy to generate test fixtures without maintaining separate factories.
10
-
11
- ### Setup
12
-
13
- Include the helpers in your test suite:
14
-
15
- ```ruby
16
- # spec/spec_helper.rb
17
- require 'servus/testing'
18
-
19
- RSpec.configure do |config|
20
- config.include Servus::Testing::ExampleBuilders
21
- end
22
- ```
23
-
24
- ### Using Schema Examples
25
-
26
- Add `example` or `examples` keywords to your schemas:
27
-
28
- ```ruby
29
- class ProcessPayment::Service < Servus::Base
30
- schema(
31
- arguments: {
32
- type: "object",
33
- properties: {
34
- user_id: { type: "integer", example: 123 },
35
- amount: { type: "number", example: 100.0 },
36
- currency: { type: "string", examples: ["USD", "EUR", "GBP"] }
37
- }
38
- },
39
- result: {
40
- type: "object",
41
- properties: {
42
- transaction_id: { type: "string", example: "txn_abc123" },
43
- status: { type: "string", example: "approved" }
44
- }
45
- },
46
- failure: {
47
- type: "object",
48
- properties: {
49
- reason: { type: "string", example: "card_declined" },
50
- decline_code: { type: "string", example: "insufficient_funds" }
51
- }
52
- }
53
- )
54
- end
55
- ```
56
-
57
- Then use the helpers in your tests:
58
-
59
- ```ruby
60
- RSpec.describe ProcessPayment::Service do
61
- it "processes payment successfully" do
62
- # Extract examples from schema and override specific values
63
- args = servus_arguments_example(ProcessPayment::Service, amount: 50.0)
64
- # => { user_id: 123, amount: 50.0, currency: "USD" }
65
-
66
- result = ProcessPayment::Service.call(**args)
67
-
68
- expect(result).to be_success
69
- expect(result.data.keys).to match_array(
70
- servus_result_example(ProcessPayment::Service).data.keys
71
- )
72
- end
73
-
74
- it "returns structured failure data on decline" do
75
- args = servus_arguments_example(ProcessPayment::Service, amount: 999_999)
76
- result = ProcessPayment::Service.call(**args)
77
-
78
- expected = servus_failure_example(ProcessPayment::Service)
79
- expect(result).to be_failure
80
- expect(result.data.keys).to match_array(expected.data.keys)
81
- end
82
-
83
- it "handles different currencies" do
84
- %w[USD EUR GBP].each do |currency|
85
- result = ProcessPayment::Service.call(
86
- **servus_arguments_example(ProcessPayment::Service, currency: currency)
87
- )
88
- expect(result).to be_success
89
- end
90
- end
91
- end
92
- ```
93
-
94
- ### Deep Merging
95
-
96
- Overrides are deep-merged with schema examples, allowing you to override nested values:
97
-
98
- ```ruby
99
- # Schema has nested structure
100
- args = servus_arguments_example(
101
- CreateUser::Service,
102
- user: { profile: { age: 35 } }
103
- )
104
- # => { user: { id: 1, profile: { name: 'Alice', age: 35 } } }
105
- ```
106
-
107
- ### Available Helpers
108
-
109
- - `servus_arguments_example(ServiceClass, **overrides)` - Returns hash of argument examples
110
- - `servus_result_example(ServiceClass, **overrides)` - Returns successful Response with result examples
111
- - `servus_failure_example(ServiceClass, **overrides)` - Returns failure Response with failure schema examples
112
-
113
- ## Basic Testing Pattern
114
-
115
- ```ruby
116
- RSpec.describe ProcessPayment::Service do
117
- describe ".call" do
118
- let(:user) { create(:user, balance: 1000) }
119
-
120
- subject(:result) { described_class.call(user_id: user.id, amount: amount) }
121
-
122
- context "with sufficient balance" do
123
- let(:amount) { 50 }
124
-
125
- it "processes payment" do
126
- expect(result.success?).to be true
127
- expect(result.data[:new_balance]).to eq(950)
128
- expect(result.reload.balance).to eq(950)
129
- end
130
- end
131
-
132
- context "with insufficient balance" do
133
- let(:amount) { 2000 }
134
-
135
- it "returns failure" do
136
- expect(result.success?).to be false
137
- expect(result.error.message).to eq("Insufficient funds")
138
- expect(result.error).to be_a(Servus::Support::Errors::ServiceError)
139
- end
140
- end
141
- end
142
- end
143
- ```
144
-
145
- ## Testing Service Composition
146
-
147
- When testing services that call other services, mock the child services:
148
-
149
- ```ruby
150
- describe Users::CreateWithAccount::Service do
151
- # Make local or global helpers to clean up tests
152
- def servus_success_result(data)
153
- Servus::Support::Response.new(success: true, data: data, error: nil)
154
- end
155
-
156
- it "calls both create services" do
157
- # Mock child services
158
- allow(Users::Create::Service).to receive(:call).and_return(servus_success_result{ user: user })
159
- allow(Accounts::Create::Service).to receive(:call).and_return(servus_success_result{ account: account })
160
-
161
- result = described_class.call(email: "test@example.com", plan: "premium")
162
-
163
- expect(Users::Create::Service).to have_received(:call)
164
- expect(Accounts::Create::Service).to have_received(:call)
165
-
166
- expect(result.success?).to be true
167
- end
168
- end
169
- ```
170
-
171
- ## Testing Schema Validation
172
-
173
- Don't test that valid arguments pass validation - that's testing the framework. Do test that your schema catches invalid inputs:
174
-
175
- ```ruby
176
- it "validates required fields" do
177
- expect {
178
- Service.call(invalid: "params")
179
- }.to raise_error(Servus::Support::Errors::ValidationError, /required/)
180
- end
181
- ```
182
-
183
- ## Testing Event Emission
184
-
185
- Servus provides RSpec matchers for testing that services emit events.
186
-
187
- ### Setup
188
-
189
- Include the matchers in your test suite:
190
-
191
- ```ruby
192
- # spec/spec_helper.rb
193
- require 'servus/testing'
194
- ```
195
-
196
- ### emit_event Matcher
197
-
198
- Assert that a block emits an event:
199
-
200
- ```ruby
201
- RSpec.describe CreateUser::Service do
202
- it 'emits user_created event on success' do
203
- expect {
204
- described_class.call(email: 'test@example.com', name: 'Test')
205
- }.to emit_event(:user_created)
206
- end
207
-
208
- it 'emits event with expected payload' do
209
- expect {
210
- described_class.call(email: 'test@example.com', name: 'Test')
211
- }.to emit_event(:user_created).with(hash_including(email: 'test@example.com'))
212
- end
213
-
214
- # Using handler class instead of symbol
215
- it 'emits to UserCreatedHandler' do
216
- expect {
217
- described_class.call(email: 'test@example.com', name: 'Test')
218
- }.to emit_event(UserCreatedHandler)
219
- end
220
- end
221
- ```
222
-
223
- ### call_service Matcher
224
-
225
- Assert that a handler invokes a service:
226
-
227
- ```ruby
228
- RSpec.describe UserCreatedHandler do
229
- let(:payload) { { user_id: 123, email: 'test@example.com' } }
230
-
231
- it 'invokes SendWelcomeEmail::Service' do
232
- expect {
233
- described_class.handle(payload)
234
- }.to call_service(SendWelcomeEmail::Service).with(user_id: 123)
235
- end
236
-
237
- it 'invokes service asynchronously' do
238
- expect {
239
- described_class.handle(payload)
240
- }.to call_service(SendWelcomeEmail::Service).async
241
- end
242
- end
243
- ```
244
-
245
- ### Testing EventHandler Directly
246
-
247
- Test handlers by calling their `handle` class method:
248
-
249
- ```ruby
250
- RSpec.describe UserCreatedHandler do
251
- let(:payload) { { user_id: 123, email: 'test@example.com' } }
252
-
253
- before do
254
- allow(SendWelcomeEmail::Service).to receive(:call_async)
255
- allow(TrackAnalytics::Service).to receive(:call_async)
256
- end
257
-
258
- it 'invokes SendWelcomeEmail with mapped arguments' do
259
- described_class.handle(payload)
260
-
261
- expect(SendWelcomeEmail::Service)
262
- .to have_received(:call_async)
263
- .with(user_id: 123, email: 'test@example.com')
264
- end
265
-
266
- it 'invokes TrackAnalytics with event data' do
267
- described_class.handle(payload)
268
-
269
- expect(TrackAnalytics::Service)
270
- .to have_received(:call_async)
271
- .with(event: 'user_created', user_id: 123)
272
- end
273
-
274
- context 'with conditional invocation' do
275
- it 'skips premium rewards for non-premium users' do
276
- allow(GrantPremiumRewards::Service).to receive(:call)
277
-
278
- described_class.handle(payload.merge(premium: false))
279
-
280
- expect(GrantPremiumRewards::Service).not_to have_received(:call)
281
- end
282
- end
283
- end
284
- ```
285
-
286
- ### Testing Event Emission from Handler
287
-
288
- Test the `emit` class method:
289
-
290
- ```ruby
291
- RSpec.describe UserCreatedHandler do
292
- it 'emits the user_created event' do
293
- expect {
294
- described_class.emit({ user_id: 123, email: 'test@example.com' })
295
- }.to emit_event(:user_created)
296
- end
297
-
298
- it 'validates payload against schema' do
299
- expect {
300
- described_class.emit({ invalid: 'payload' })
301
- }.to raise_error(Servus::Support::Errors::ValidationError)
302
- end
303
- end
304
- ```