Skip to content

Commit 643b0fa

Browse files
committed
Cursor rules. Update behavior of fail_immediately!
1 parent 309b70f commit 643b0fa

File tree

21 files changed

+683
-53
lines changed

21 files changed

+683
-53
lines changed
Lines changed: 354 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,354 @@
1+
---
2+
description: "Rules for writing RSpec tests for Light Services - testing arguments, steps, outputs, and behavior"
3+
globs: "**/spec/services/*_spec.rb"
4+
alwaysApply: false
5+
---
6+
7+
# Light Services RSpec Testing Rules
8+
9+
When writing RSpec tests for services that inherit from `Light::Services::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 "light/services/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(Light::Services::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(Light::Services::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(Light::Services::ArgTypeError)
231+
232+
# Wrong type
233+
expect { described_class.run(name: 123) }.to raise_error(Light::Services::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 < Light::Services::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 < Light::Services::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 commit comments

Comments
 (0)