steroids 1.0.0 → 1.6.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 (51) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +854 -0
  3. data/Rakefile +27 -0
  4. data/app/jobs/steroids/async_service_job.rb +10 -0
  5. data/app/serializers/steroids/error_serializer.rb +35 -0
  6. data/lib/resources/quotes.yml +114 -0
  7. data/lib/steroids/controllers/methods.rb +18 -0
  8. data/lib/{concerns/controller.rb → steroids/controllers/responders_helper.rb} +20 -21
  9. data/lib/steroids/controllers/serializers_helper.rb +15 -0
  10. data/lib/steroids/engine.rb +6 -0
  11. data/lib/steroids/errors/base.rb +57 -0
  12. data/lib/steroids/errors/context.rb +70 -0
  13. data/lib/steroids/errors/quotes.rb +29 -0
  14. data/lib/steroids/errors.rb +89 -0
  15. data/lib/steroids/extensions/array_extension.rb +25 -0
  16. data/lib/steroids/extensions/class_extension.rb +141 -0
  17. data/lib/steroids/extensions/hash_extension.rb +14 -0
  18. data/lib/steroids/extensions/method_extension.rb +63 -0
  19. data/lib/steroids/extensions/module_extension.rb +32 -0
  20. data/lib/steroids/extensions/object_extension.rb +122 -0
  21. data/lib/steroids/extensions/proc_extension.rb +9 -0
  22. data/lib/steroids/logger.rb +162 -0
  23. data/lib/steroids/railtie.rb +60 -0
  24. data/lib/steroids/serializers/base.rb +7 -0
  25. data/lib/{concerns/serializer.rb → steroids/serializers/methods.rb} +3 -3
  26. data/lib/steroids/services/base.rb +181 -0
  27. data/lib/steroids/support/magic_class.rb +17 -0
  28. data/lib/steroids/support/noticable_methods.rb +134 -0
  29. data/lib/steroids/support/servicable_methods.rb +34 -0
  30. data/lib/{base/type.rb → steroids/types/base.rb} +3 -3
  31. data/lib/{base/model.rb → steroids/types/serializable_type.rb} +2 -2
  32. data/lib/steroids/version.rb +4 -0
  33. data/lib/steroids.rb +12 -0
  34. metadata +75 -34
  35. data/lib/base/class.rb +0 -15
  36. data/lib/base/error.rb +0 -87
  37. data/lib/base/hash.rb +0 -49
  38. data/lib/base/list.rb +0 -51
  39. data/lib/base/service.rb +0 -104
  40. data/lib/concern.rb +0 -130
  41. data/lib/concerns/error.rb +0 -20
  42. data/lib/concerns/model.rb +0 -9
  43. data/lib/errors/bad_request_error.rb +0 -15
  44. data/lib/errors/conflict_error.rb +0 -15
  45. data/lib/errors/forbidden_error.rb +0 -15
  46. data/lib/errors/generic_error.rb +0 -14
  47. data/lib/errors/internal_server_error.rb +0 -15
  48. data/lib/errors/not_found_error.rb +0 -15
  49. data/lib/errors/not_implemented_error.rb +0 -15
  50. data/lib/errors/unauthorized_error.rb +0 -15
  51. data/lib/errors/unprocessable_entity_error.rb +0 -15
data/README.md ADDED
@@ -0,0 +1,854 @@
1
+ # Steroids
2
+
3
+ [![Gem Version](https://img.shields.io/badge/version-1.6.0-green)](https://rubygems.org/gems/steroids)
4
+ [![Rails](https://img.shields.io/badge/Rails-%3E%3D%207.1-red)](https://rubyonrails.org/)
5
+ [![Ruby](https://img.shields.io/badge/Ruby-%3E%3D%203.0-red)](https://www.ruby-lang.org/)
6
+ [![Tests](https://img.shields.io/badge/tests-78%20passing-brightgreen)](https://github.com/somelibs/steroids)
7
+ [![License](https://img.shields.io/badge/License-MIT-blue)](LICENSE.md)
8
+
9
+ **Steroids** supercharges your Rails applications with powerful service objects, enhanced error handling, and useful Ruby extensions. Build maintainable, testable business logic with a battle-tested service layer pattern.
10
+
11
+ ## Table of Contents
12
+
13
+ - [Getting Started](#getting-started)
14
+ - [Service Objects](#service-objects)
15
+ - [Error Handling](#error-handling)
16
+ - [Controller Integration](#controller-integration)
17
+ - [Async Services](#async-services)
18
+ - [Serializers (Deprecated)](#serializers-deprecated)
19
+ - [Error Classes](#error-classes)
20
+ - [Logger](#logger)
21
+ - [Extensions](#extensions)
22
+ - [Testing](#testing)
23
+ - [Configuration](#configuration)
24
+ - [Contributing](#contributing)
25
+ - [License](#license)
26
+
27
+ ## Getting Started
28
+
29
+ ### Requirements
30
+
31
+ - Ruby 3.0+
32
+ - Rails 7.1+
33
+ - Sidekiq (optional, for async services)
34
+
35
+ ### Installation
36
+
37
+ Add Steroids to your application's Gemfile:
38
+
39
+ ```ruby
40
+ # From GitHub (recommended during active development)
41
+ gem 'steroids', git: 'git@github.com:somelibs/steroids.git', branch: 'master'
42
+
43
+ # Or from RubyGems (when published)
44
+ gem 'steroids'
45
+ ```
46
+
47
+ And then execute:
48
+
49
+ ```bash
50
+ $ bundle install
51
+ ```
52
+
53
+ ## Service Objects
54
+
55
+ Steroids provides a powerful service object pattern for encapsulating business logic.
56
+
57
+ ### Basic Service
58
+
59
+ ```ruby
60
+ class CreateUserService < Steroids::Services::Base
61
+ success_notice "User created successfully"
62
+
63
+ def initialize(name:, email:, role: 'user')
64
+ @name = name
65
+ @email = email
66
+ @role = role
67
+ end
68
+
69
+ def process
70
+ user = User.create!(
71
+ name: @name,
72
+ email: @email,
73
+ role: @role
74
+ )
75
+
76
+ UserMailer.welcome(user).deliver_later
77
+ user # Return value becomes the service call result
78
+ rescue ActiveRecord::RecordInvalid => e
79
+ errors.add("Failed to create user: #{e.message}", e)
80
+ nil # Return nil on failure
81
+ end
82
+ end
83
+ ```
84
+
85
+ ### Usage Patterns
86
+
87
+ ```ruby
88
+ # Method 1: Direct call with block (RECOMMENDED for controllers)
89
+ CreateUserService.call(name: "John", email: "john@example.com") do |service|
90
+ if service.success?
91
+ redirect_to users_path, notice: service.notice
92
+ else
93
+ flash.now[:alert] = service.errors.full_messages
94
+ render :new
95
+ end
96
+ end
97
+
98
+ # Method 2: Get return value directly
99
+ user = CreateUserService.call(name: "John", email: "john@example.com")
100
+ # user is the return value from process method (User object or nil)
101
+
102
+ # Method 3: Check service instance
103
+ service = CreateUserService.new(name: "John", email: "john@example.com")
104
+ result = service.call
105
+
106
+ if service.success?
107
+ puts service.notice # => "User created successfully"
108
+ # result contains the User object
109
+ else
110
+ puts service.errors.full_messages
111
+ # result is nil
112
+ end
113
+ ```
114
+
115
+ ### Important Behaviors
116
+
117
+ **Block Parameters**: When using blocks, the service instance is passed as the first parameter:
118
+ ```ruby
119
+ CreateUserService.call(name: "John") do |service|
120
+ # service contains the service instance with noticable methods
121
+ if service.success?
122
+ # handle success
123
+ end
124
+ end
125
+ ```
126
+
127
+ **Return Values**:
128
+ - Without a block: `call` returns the result of the `process` method
129
+ - With a block: `call` returns the result of the `process` method, and yields the service instance to the block
130
+
131
+ ```ruby
132
+ # Without block - returns process result directly
133
+ user = CreateUserService.call(name: "John", email: "john@example.com")
134
+ # user is the User object (or nil if failed)
135
+
136
+ # With block - still returns process result, but yields service for status checking
137
+ user = CreateUserService.call(name: "John", email: "john@example.com") do |service|
138
+ if service.errors?
139
+ # Handle errors using service.errors
140
+ end
141
+ end
142
+ # user is still the User object (or nil if failed)
143
+ ```
144
+
145
+ ### Service with Validations
146
+
147
+ ```ruby
148
+ class UpdateProfileService < Steroids::Services::Base
149
+ success_notice "Profile updated"
150
+
151
+ def initialize(user:, params:)
152
+ @user = user
153
+ @params = params
154
+ end
155
+
156
+ private
157
+
158
+ def process
159
+ validate_params!
160
+ @user.update!(@params)
161
+ rescue StandardError => e
162
+ errors.add("Update failed: #{e.message}", e)
163
+ end
164
+
165
+ def validate_params!
166
+ if @params[:email].blank?
167
+ errors.add("Email cannot be blank")
168
+ drop! # Halts execution
169
+ end
170
+ end
171
+ end
172
+ ```
173
+
174
+ ### Service Callbacks
175
+
176
+ ```ruby
177
+ class ProcessPaymentService < Steroids::Services::Base
178
+ before_process :validate_payment
179
+ after_process :send_receipt
180
+
181
+ def initialize(order:, payment_method:)
182
+ @order = order
183
+ @payment_method = payment_method
184
+ end
185
+
186
+ def process
187
+ @payment = Payment.create!(
188
+ order: @order,
189
+ amount: @order.total,
190
+ method: @payment_method
191
+ )
192
+ end
193
+
194
+ private
195
+
196
+ def validate_payment
197
+ drop!("Invalid payment amount") if @order.total <= 0
198
+ end
199
+
200
+ def send_receipt(payment)
201
+ PaymentMailer.receipt(payment).deliver_later
202
+ end
203
+ end
204
+ ```
205
+
206
+ ## Error Handling
207
+
208
+ **⚠️ IMPORTANT:** Steroids uses a different error handling pattern than ActiveRecord.
209
+
210
+ ### Correct Usage
211
+
212
+ ```ruby
213
+ # ✅ CORRECT - Steroids pattern
214
+ errors.add("Something went wrong")
215
+ errors.add("Operation failed", exception)
216
+ notices.add("Processing started")
217
+ ```
218
+
219
+ ### Incorrect Usage
220
+
221
+ ```ruby
222
+ # ❌ WRONG - ActiveRecord pattern (will NOT work)
223
+ errors.add(:base, "Something went wrong")
224
+ errors.add(:field, "is invalid")
225
+ ```
226
+
227
+ ### Error Flow Control
228
+
229
+ ```ruby
230
+ class ComplexService < Steroids::Services::Base
231
+ def process
232
+ # Method 1: Add error and return
233
+ if condition_failed?
234
+ errors.add("Condition not met")
235
+ return
236
+ end
237
+
238
+ # Method 2: Drop with message (halts execution)
239
+ drop!("Critical failure") if critical_error?
240
+
241
+ # Method 3: Automatic drop on errors
242
+ validate_something # adds errors
243
+ # Service automatically drops if errors.any? is true
244
+ end
245
+
246
+ def rescue!(exception)
247
+ # Handle any uncaught exceptions
248
+ logger.error "Service failed: #{exception.message}"
249
+ errors.add("An unexpected error occurred")
250
+ end
251
+
252
+ def ensure!
253
+ # Always runs, even on failure
254
+ cleanup_resources
255
+ end
256
+ end
257
+ ```
258
+
259
+ ## Controller Integration
260
+
261
+ ### Using the Service Macro
262
+
263
+ ```ruby
264
+ class UsersController < ApplicationController
265
+ # Define service with custom class
266
+ service :create_user, class_name: "Users::CreateService"
267
+ service :update_user, class_name: "Users::UpdateService"
268
+
269
+ def create
270
+ create_user(user_params) do |service|
271
+ if service.success?
272
+ redirect_to users_path, notice: service.notice
273
+ else
274
+ @user = User.new(user_params)
275
+ flash.now[:alert] = service.errors.full_messages
276
+ render :new
277
+ end
278
+ end
279
+ end
280
+
281
+ def update
282
+ update_user(user: @user, params: user_params) do |service|
283
+ if service.success?
284
+ redirect_to @user, notice: service.notice
285
+ else
286
+ flash.now[:alert] = service.errors.full_messages
287
+ render :edit
288
+ end
289
+ end
290
+ end
291
+
292
+ private
293
+
294
+ def user_params
295
+ params.require(:user).permit(:name, :email, :role)
296
+ end
297
+ end
298
+ ```
299
+
300
+ ### Direct Service Call
301
+
302
+ ```ruby
303
+ class OrdersController < ApplicationController
304
+ def complete
305
+ service = CompleteOrderService.call(order: @order, payment_id: params[:payment_id])
306
+
307
+ respond_to do |format|
308
+ if service.success?
309
+ format.html { redirect_to @order, notice: service.notice }
310
+ format.json { render json: { message: service.notice }, status: :ok }
311
+ else
312
+ format.html { redirect_to @order, alert: service.errors.full_messages }
313
+ format.json { render json: { errors: service.errors.to_a }, status: :unprocessable_entity }
314
+ end
315
+ end
316
+ end
317
+ end
318
+ ```
319
+
320
+ ## Async Services
321
+
322
+ Services can run asynchronously using Sidekiq. **Important:** In development, test environments, and Rails console, async services automatically run synchronously for easier debugging.
323
+
324
+ ### Defining an Async Service
325
+
326
+ ```ruby
327
+ class SendNewsletterService < Steroids::Services::Base
328
+ success_notice "Newsletter sent to all subscribers"
329
+
330
+ def initialize(subject:, content:)
331
+ @subject = subject
332
+ @content = content
333
+ end
334
+
335
+ # Use async_process instead of process
336
+ def async_process
337
+ User.subscribed.find_each do |user|
338
+ NewsletterMailer.weekly(user, @subject, @content).deliver_now
339
+ end
340
+ rescue StandardError => e
341
+ errors.add("Newsletter delivery failed", e)
342
+ end
343
+ end
344
+
345
+ # Behavior varies by environment:
346
+ # - Production with Sidekiq running: Runs in background
347
+ # - Development/Test/Console: Runs synchronously (immediate execution)
348
+ SendNewsletterService.call(subject: "Weekly Update", content: "...")
349
+
350
+ # Force synchronous execution in any environment
351
+ SendNewsletterService.call(subject: "Test", content: "...", async: false)
352
+ ```
353
+
354
+ ### Async Execution Logic
355
+
356
+ The service automatically determines execution mode based on:
357
+
358
+ ```ruby
359
+ # Runs async when ALL conditions are met:
360
+ # 1. Sidekiq is running (workers available)
361
+ # 2. NOT in Rails console
362
+ # 3. NOT in development (unless Sidekiq is running)
363
+ # 4. async: true (default)
364
+
365
+ # Otherwise runs synchronously for easier debugging
366
+ ```
367
+
368
+ ### Important Notes for Async Services
369
+
370
+ 1. **Parameters must be serializable** (strings, numbers, hashes, arrays)
371
+ 2. **Don't pass ActiveRecord objects** - pass IDs instead
372
+ 3. **Use `async_process` method** instead of `process`
373
+ 4. **Runs via `AsyncServiceJob`** with Sidekiq in production
374
+ 5. **Auto-synchronous in dev/test** for easier debugging
375
+
376
+ ```ruby
377
+ # ❌ WRONG - AR object won't serialize
378
+ AsyncService.call(user: current_user)
379
+
380
+ # ✅ CORRECT - Pass serializable data
381
+ AsyncService.call(user_id: current_user.id)
382
+ ```
383
+
384
+ ## Serializers (Deprecated)
385
+
386
+ > **⚠️ DEPRECATION WARNING:** The Serializers module will be removed in the next major version. Consider using [ActiveModel::Serializer](https://github.com/rails-api/active_model_serializers) or [Blueprinter](https://github.com/procore/blueprinter) directly.
387
+
388
+ Steroids provides a thin wrapper around ActiveModel::Serializer:
389
+
390
+ ```ruby
391
+ class UserSerializer < Steroids::Serializers::Base
392
+ attributes :id, :name, :email, :role
393
+ has_many :posts
394
+
395
+ def custom_attribute
396
+ object.some_computed_value
397
+ end
398
+ end
399
+
400
+ # Usage
401
+ serializer = UserSerializer.new(user)
402
+ serializer.to_json
403
+ ```
404
+
405
+ ## Error Classes
406
+
407
+ Steroids provides a comprehensive error hierarchy with HTTP status codes and logging capabilities.
408
+
409
+ ### Base Error Class
410
+
411
+ ```ruby
412
+ class CustomError < Steroids::Errors::Base
413
+ self.default_message = "Something went wrong"
414
+ self.default_status = :internal_server_error
415
+ end
416
+
417
+ # Usage with various options
418
+ raise CustomError.new("Specific error message")
419
+ raise CustomError.new(
420
+ message: "Error occurred",
421
+ status: :bad_request,
422
+ code: "ERR_001",
423
+ cause: original_exception,
424
+ context: { user_id: 123 },
425
+ log: true # Automatically log the error
426
+ )
427
+
428
+ # Access error properties
429
+ begin
430
+ # some code
431
+ rescue CustomError => e
432
+ e.message # Error message
433
+ e.status # HTTP status symbol
434
+ e.code # Custom error code
435
+ e.cause # Original exception if any
436
+ e.context # Additional context
437
+ e.timestamp # When the error occurred
438
+ end
439
+ ```
440
+
441
+ ### Pre-defined HTTP Error Classes
442
+
443
+ ```ruby
444
+ # 400 Bad Request
445
+ raise Steroids::Errors::BadRequestError.new("Invalid parameters")
446
+
447
+ # 401 Unauthorized
448
+ raise Steroids::Errors::UnauthorizedError.new("Please login")
449
+
450
+ # 403 Forbidden
451
+ raise Steroids::Errors::ForbiddenError.new("Access denied")
452
+
453
+ # 404 Not Found
454
+ raise Steroids::Errors::NotFoundError.new("Resource not found")
455
+
456
+ # 409 Conflict
457
+ raise Steroids::Errors::ConflictError.new("Resource already exists")
458
+
459
+ # 422 Unprocessable Entity
460
+ raise Steroids::Errors::UnprocessableEntityError.new("Validation failed")
461
+
462
+ # 500 Internal Server Error
463
+ raise Steroids::Errors::InternalServerError.new("Server error")
464
+
465
+ # 501 Not Implemented
466
+ raise Steroids::Errors::NotImplementedError.new("Feature coming soon")
467
+ ```
468
+
469
+ ### Error Serialization
470
+
471
+ Errors can be serialized for API responses:
472
+
473
+ ```ruby
474
+ class ApiController < ApplicationController
475
+ rescue_from Steroids::Errors::Base do |error|
476
+ render json: error.to_json, status: error.status
477
+ end
478
+ end
479
+ ```
480
+
481
+ ### Error Context and Logging
482
+
483
+ ```ruby
484
+ # Add context for debugging
485
+ error = Steroids::Errors::BadRequestError.new(
486
+ "Invalid input",
487
+ context: {
488
+ user_id: current_user.id,
489
+ params: params.to_unsafe_h,
490
+ timestamp: Time.current
491
+ },
492
+ log: true # Will automatically log with Steroids::Logger
493
+ )
494
+
495
+ # Manual logging
496
+ error.log! # Logs the error with full backtrace
497
+ ```
498
+
499
+ ## Logger
500
+
501
+ Steroids provides an enhanced logger with colored output, backtrace formatting, and error notification support.
502
+
503
+ ### Basic Usage
504
+
505
+ ```ruby
506
+ # Simple logging
507
+ Steroids::Logger.print("Operation completed")
508
+ Steroids::Logger.print("Warning message", verbosity: :concise)
509
+
510
+ # Logging exceptions
511
+ begin
512
+ risky_operation
513
+ rescue => e
514
+ Steroids::Logger.print(e) # Automatically detects error level
515
+ end
516
+ ```
517
+
518
+ ### Verbosity Levels
519
+
520
+ ```ruby
521
+ # Full backtrace (default for exceptions)
522
+ Steroids::Logger.print(exception, verbosity: :full)
523
+
524
+ # Concise backtrace (app code only)
525
+ Steroids::Logger.print(exception, verbosity: :concise)
526
+
527
+ # No backtrace
528
+ Steroids::Logger.print(exception, verbosity: :none)
529
+ ```
530
+
531
+ ### Format Options
532
+
533
+ ```ruby
534
+ # Decorated output with colors (default)
535
+ Steroids::Logger.print("Message", format: :decorated)
536
+
537
+ # Raw output without colors
538
+ Steroids::Logger.print("Message", format: :raw)
539
+ ```
540
+
541
+ ### Automatic Log Levels
542
+
543
+ The logger automatically determines the appropriate log level:
544
+
545
+ - **`:error`** - For `StandardError`, `InternalServerError`, `GenericError`
546
+ - **`:warn`** - For other `Steroids::Errors::Base` subclasses
547
+ - **`:info`** - For regular messages
548
+
549
+ ### Error Notifications
550
+
551
+ Configure a notifier to receive alerts for errors:
552
+
553
+ ```ruby
554
+ # In an initializer
555
+ Steroids::Logger.notifier = lambda do |error|
556
+ # Send to error tracking service
557
+ Bugsnag.notify(error)
558
+ # Or send to Slack
559
+ SlackNotifier.alert(error.message)
560
+ end
561
+ ```
562
+
563
+ ### Colored Output
564
+
565
+ The logger uses Rainbow for colored terminal output:
566
+
567
+ - 🔴 **Red** - Errors
568
+ - 🟡 **Yellow** - Warnings
569
+ - 🟢 **Green** - Info messages
570
+ - 🟣 **Magenta** - Error class names and quiet logs
571
+
572
+ ### Integration with Services
573
+
574
+ Services automatically use the logger for error handling:
575
+
576
+ ```ruby
577
+ class MyService < Steroids::Services::Base
578
+ def process
579
+ Steroids::Logger.print("Starting process")
580
+
581
+ perform_operation
582
+
583
+ Steroids::Logger.print("Process completed")
584
+ rescue => e
585
+ Steroids::Logger.print(e) # Full error logging with backtrace
586
+ errors.add("Process failed", e)
587
+ end
588
+ end
589
+ ```
590
+
591
+ ## Extensions
592
+
593
+ Steroids provides useful extensions to Ruby core classes.
594
+
595
+ ### Type Checking
596
+
597
+ ```ruby
598
+ # Ensure type at runtime
599
+ def process_name(name)
600
+ name.typed!(String) # Raises TypeError if not a String
601
+ name.upcase
602
+ end
603
+
604
+ # Type casting with enums
605
+ STATUSES = %i[draft published archived]
606
+ status = STATUSES.cast(:published) # Returns :published
607
+ status = STATUSES.cast(:invalid) # Raises error
608
+ ```
609
+
610
+ ### Hash Extensions
611
+
612
+ ```ruby
613
+ # Check if hash is serializable
614
+ params.serializable? # => true/false
615
+
616
+ # Deep serialize for storage
617
+ data = { user: { name: "John", tags: ["ruby", "rails"] } }
618
+ serialized = data.deep_serialize
619
+ ```
620
+
621
+ ### Safe Method Calls
622
+
623
+ ```ruby
624
+ # Safe send with fallback
625
+ object.send_apply(:optional_method, arg1, arg2)
626
+
627
+ # Try to get method object
628
+ method_obj = object.try_method(:method_name)
629
+ ```
630
+
631
+ ## Testing
632
+
633
+ ### RSpec Examples
634
+
635
+ ```ruby
636
+ RSpec.describe CreateUserService do
637
+ describe "#call" do
638
+ context "with valid params" do
639
+ subject { described_class.call(name: "John", email: "john@test.com") }
640
+
641
+ it "succeeds" do
642
+ expect(subject).to be_success
643
+ expect(subject.errors).not_to be_any
644
+ end
645
+
646
+ it "creates a user" do
647
+ expect { subject }.to change(User, :count).by(1)
648
+ end
649
+
650
+ it "returns success notice" do
651
+ expect(subject.notice).to eq("User created successfully")
652
+ end
653
+ end
654
+
655
+ context "with invalid params" do
656
+ subject { described_class.call(name: "", email: "invalid") }
657
+
658
+ it "fails" do
659
+ expect(subject).to be_errors
660
+ expect(subject).not_to be_success
661
+ end
662
+
663
+ it "returns error messages" do
664
+ expect(subject.errors.full_messages).to include(/failed/i)
665
+ end
666
+ end
667
+ end
668
+ end
669
+ ```
670
+
671
+ ### Testing Async Services
672
+
673
+ ```ruby
674
+ RSpec.describe AsyncNewsletterService do
675
+ it "enqueues job" do
676
+ expect {
677
+ described_class.call(subject: "Test", content: "Content")
678
+ }.to have_enqueued_job(AsyncServiceJob)
679
+ end
680
+
681
+ it "processes synchronously when forced" do
682
+ service = described_class.call(subject: "Test", content: "Content", async: false)
683
+ expect(service).to be_success
684
+ end
685
+ end
686
+ ```
687
+
688
+ ## Configuration
689
+
690
+ ### Transaction Wrapping
691
+
692
+ Services are wrapped in database transactions by default:
693
+
694
+ ```ruby
695
+ class MyService < Steroids::Services::Base
696
+ # Disable transaction wrapping for this service
697
+ self.wrap_in_transaction = false
698
+
699
+ def process
700
+ # Not wrapped in transaction
701
+ end
702
+ end
703
+ ```
704
+
705
+ ### Callback Configuration
706
+
707
+ ```ruby
708
+ class MyService < Steroids::Services::Base
709
+ # Skip all callbacks
710
+ self.skip_callbacks = true
711
+
712
+ # Or skip per invocation
713
+ def process
714
+ MyService.call(data: data, skip_callbacks: true)
715
+ end
716
+ end
717
+ ```
718
+
719
+ ## Development
720
+
721
+ ### Local Development
722
+
723
+ When developing Steroids locally alongside a Rails application, you can use Bundler's local gem override:
724
+
725
+ ```bash
726
+ # Point Bundler to your local Steroids repository
727
+ $ bundle config local.steroids /path/to/local/steroids
728
+
729
+ # Example:
730
+ $ bundle config local.steroids ~/Projects/steroids
731
+
732
+ # Verify the configuration
733
+ $ bundle config
734
+ # Should show: local.steroids => "/path/to/local/steroids"
735
+
736
+ # Install/update dependencies
737
+ $ bundle install
738
+ ```
739
+
740
+ Now your Rails app will use the local version of Steroids. Any changes you make to the gem will be reflected immediately (after restarting Rails).
741
+
742
+ To remove the local override:
743
+
744
+ ```bash
745
+ $ bundle config --delete local.steroids
746
+ $ bundle install
747
+ ```
748
+
749
+ ### Running Tests
750
+
751
+ Steroids uses Minitest for testing. The test suite includes comprehensive coverage of:
752
+ - Service objects and lifecycle
753
+ - Noticable methods (error/notice handling)
754
+ - Controller integration (servicable methods)
755
+ - Error classes and logging
756
+ - Async services
757
+
758
+ #### Run All Tests
759
+
760
+ ```bash
761
+ # Using Rake (recommended)
762
+ $ bundle exec rake test
763
+
764
+ # With verbose output
765
+ $ bundle exec rake test TESTOPTS="--verbose"
766
+ ```
767
+
768
+ #### Run Specific Test Files
769
+
770
+ ```bash
771
+ # Test services
772
+ $ bundle exec rake test TEST=test/services/base_service_test.rb
773
+ $ bundle exec rake test TEST=test/services/async_service_test.rb
774
+
775
+ # Test support modules
776
+ $ bundle exec rake test TEST=test/support/noticable_methods_test.rb
777
+ $ bundle exec rake test TEST=test/support/servicable_methods_test.rb
778
+
779
+ # Test errors
780
+ $ bundle exec rake test TEST=test/errors/base_error_test.rb
781
+
782
+ # Main module test
783
+ $ bundle exec rake test TEST=test/steroids_test.rb
784
+ ```
785
+
786
+ #### Run Tests by Pattern
787
+
788
+ ```bash
789
+ # Run all service tests
790
+ $ bundle exec rake test TEST="test/services/*"
791
+
792
+ # Run multiple specific tests
793
+ $ bundle exec rake test TEST="test/services/base_service_test.rb,test/support/noticable_methods_test.rb"
794
+ ```
795
+
796
+ #### Test Coverage
797
+
798
+ To check test coverage (requires simplecov gem):
799
+
800
+ ```bash
801
+ # Add to Gemfile (test group)
802
+ gem 'simplecov', require: false
803
+
804
+ # Add to test_helper.rb (at the top)
805
+ require 'simplecov'
806
+ SimpleCov.start 'rails'
807
+
808
+ # Run tests and generate coverage report
809
+ $ bundle exec rake test
810
+ # Coverage report will be in coverage/index.html
811
+ ```
812
+
813
+ ## Troubleshooting
814
+
815
+ ### Common Issues
816
+
817
+ **Issue:** `TypeError: Expected String instance`
818
+ **Solution:** Ensure you're using `errors.add("message")` not `errors.add(:symbol, "message")`
819
+
820
+ **Issue:** Async service not running
821
+ **Solution:** Ensure Sidekiq is running and parameters are serializable
822
+
823
+ **Issue:** Transaction rollback not working
824
+ **Solution:** Ensure `wrap_in_transaction` is not disabled
825
+
826
+ **Issue:** `force` flag not preventing service from dropping
827
+ **Solution:** The `force: true` option may not work as expected in all cases. Currently, the force flag behavior is being reviewed.
828
+
829
+ **Issue:** `skip_callbacks` option not working properly
830
+ **Solution:** The `skip_callbacks: true` option may not skip all callbacks as expected. This is a known limitation being addressed.
831
+
832
+ ## Roadmap
833
+
834
+ - [ ] Standalone testing with dummy Rails app
835
+ - [ ] Generator for service objects
836
+ - [ ] Built-in metrics and instrumentation
837
+ - [ ] Service composition patterns
838
+ - [ ] Enhanced async job features
839
+
840
+ ## Contributing
841
+
842
+ Bug reports and pull requests are welcome on GitHub at https://github.com/somelibs/steroids.
843
+
844
+ ## Disclaimer
845
+
846
+ This gem is under active development and may not strictly follow SemVer. Use at your own risk in production environments.
847
+
848
+ ## Credits
849
+
850
+ Created and maintained by Paul R.
851
+
852
+ ## License
853
+
854
+ The gem is available as open source under the terms of the [MIT License](LICENSE.md).