discourse/spec/lib/service/steps_inspector_spec.rb
Loïc Guitaut 3ed866a6c9
DEV: Add each step to service framework for collection iteration (#38759)
Services that process collections (bulk delete, bulk create, etc.)
currently require manual iteration inside a `step`, losing the
framework's built-in error handling and inspection. This adds a
first-class `each` step that brings the full service DSL inside the
iteration loop.

```ruby
  each :users do
    policy :can_delete
    step :destroy
  end
```

Each iteration receives the singularized item name (`user:`) and
`index:` as keyword arguments. If any nested step fails, iteration stops
and the failing item/index remain in context for error reporting.
Existing matchers (`fail_a_policy`, `fail_a_step`, etc.) work inside
each blocks.

A `persist:` option allows values to accumulate across iterations and
survive the loop's variable isolation:

```ruby
  each :tag_names, persist: { results: -> { { created: [], failed: [] } } } do
    step :create_tag
  end
  # context[:results] available after the loop
```

The steps inspector displays iteration progress ((3/3) on success, (1/3)
when failing at the first item, (empty collection) with ⏭️ when
skipped).
2026-04-03 09:44:03 +02:00

523 lines
15 KiB
Ruby
Vendored
Raw Permalink Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# frozen_string_literal: true
RSpec.describe Service::StepsInspector do
class DummyService
include Service::Base
options do
attribute :my_option, :boolean, default: true
attribute :my_other_option, :integer, default: 1
end
model :model
policy :policy
params do
attribute :parameter
attribute :other_param, :integer
validates :parameter, presence: true
end
lock(:parameter, :other_param) do
transaction do
step :in_transaction_step_1
step :in_transaction_step_2
end
end
try { step :might_raise }
only_if(:condition) { step :optional_step }
each :things do
step :in_each_step_1
step :in_each_step_2
end
step :final_step
end
subject(:inspector) { described_class.new(result) }
let(:parameter) { "present" }
let(:result) { DummyService.call(params: { parameter: }, things:) }
let(:things) { %i[item_1 item_2 item_3] }
before do
class DummyService
%i[
fetch_model
policy
in_transaction_step_1
in_transaction_step_2
might_raise
condition
optional_step
in_each_step_1
in_each_step_2
final_step
].each { |name| define_method(name) { true } }
end
end
describe "#execution_flow" do
subject(:output) do
inspector.execution_flow.strip.gsub(%r{ \(\d+\.\d+ ms\)}, "").gsub(/\e\[\d+(;\d+)?m/, "")
end
context "when service runs without error" do
it "outputs all the steps of the service" do
expect(output).to eq <<~OUTPUT.chomp
[ 1/16] [options] default
[ 2/16] [model] model
[ 3/16] [policy] policy
[ 4/16] [params] default
[ 5/16] [lock] parameter:other_param
[ 6/16] [transaction]
[ 7/16] [step] in_transaction_step_1
[ 8/16] [step] in_transaction_step_2
[ 9/16] [try]
[10/16] [step] might_raise
[11/16] [only_if] condition
[12/16] [step] optional_step
[13/16] [each] things (3/3)
[14/16] [step] in_each_step_1
[15/16] [step] in_each_step_2
[16/16] [step] final_step
OUTPUT
end
it "outputs time taken by each step" do
expect(inspector.execution_flow).to match(/\d+\.\d+ ms/)
end
end
context "when the model step is failing" do
before do
class DummyService
def fetch_model
false
end
end
end
it "shows the failing step" do
expect(output).to eq <<~OUTPUT.chomp
[ 1/16] [options] default
[ 2/16] [model] model
(14 more steps not shown as the execution flow was stopped before reaching them)
OUTPUT
end
end
context "when the policy step is failing" do
before do
class DummyService
def policy
false
end
end
end
it "shows the failing step" do
expect(output).to eq <<~OUTPUT.chomp
[ 1/16] [options] default
[ 2/16] [model] model
[ 3/16] [policy] policy
(13 more steps not shown as the execution flow was stopped before reaching them)
OUTPUT
end
end
context "when the params step is failing" do
let(:parameter) { nil }
it "shows the failing step" do
expect(output).to eq <<~OUTPUT.chomp
[ 1/16] [options] default
[ 2/16] [model] model
[ 3/16] [policy] policy
[ 4/16] [params] default
(12 more steps not shown as the execution flow was stopped before reaching them)
OUTPUT
end
end
context "when a common step is failing" do
before do
class DummyService
def in_transaction_step_2
fail!("step error")
end
end
end
it "shows the failing step" do
expect(output).to eq <<~OUTPUT.chomp
[ 1/16] [options] default
[ 2/16] [model] model
[ 3/16] [policy] policy
[ 4/16] [params] default
[ 5/16] [lock] parameter:other_param
[ 6/16] [transaction]
[ 7/16] [step] in_transaction_step_1
[ 8/16] [step] in_transaction_step_2
(8 more steps not shown as the execution flow was stopped before reaching them)
OUTPUT
end
end
context "when a step raises an exception inside the 'try' block" do
before do
class DummyService
def might_raise
raise "BOOM"
end
end
end
it "shows the failing step" do
expect(output).to eq <<~OUTPUT.chomp
[ 1/16] [options] default
[ 2/16] [model] model
[ 3/16] [policy] policy
[ 4/16] [params] default
[ 5/16] [lock] parameter:other_param
[ 6/16] [transaction]
[ 7/16] [step] in_transaction_step_1
[ 8/16] [step] in_transaction_step_2
[ 9/16] [try]
[10/16] [step] might_raise 💥
(6 more steps not shown as the execution flow was stopped before reaching them)
OUTPUT
end
end
context "when the lock step is failing" do
before { allow(DistributedMutex).to receive(:synchronize) }
it "shows the failing step" do
expect(output).to eq <<~OUTPUT.chomp
[ 1/16] [options] default
[ 2/16] [model] model
[ 3/16] [policy] policy
[ 4/16] [params] default
[ 5/16] [lock] parameter:other_param
(11 more steps not shown as the execution flow was stopped before reaching them)
OUTPUT
end
end
context "when the only_if step condition is not met" do
before do
class DummyService
def condition
false
end
end
end
it "shows the block was skipped" do
expect(output).to eq <<~OUTPUT.chomp
[ 1/16] [options] default
[ 2/16] [model] model
[ 3/16] [policy] policy
[ 4/16] [params] default
[ 5/16] [lock] parameter:other_param
[ 6/16] [transaction]
[ 7/16] [step] in_transaction_step_1
[ 8/16] [step] in_transaction_step_2
[ 9/16] [try]
[10/16] [step] might_raise
[11/16] [only_if] condition (condition was not met)
[12/16] [step] optional_step
[13/16] [each] things (3/3)
[14/16] [step] in_each_step_1
[15/16] [step] in_each_step_2
[16/16] [step] final_step
OUTPUT
end
end
context "when a step inside the each block is failing" do
before do
class DummyService
def in_each_step_2(thing:)
fail!("error on #{thing}")
end
end
end
it "shows the failing step inside the each block" do
expect(output).to eq <<~OUTPUT.chomp
[ 1/16] [options] default
[ 2/16] [model] model
[ 3/16] [policy] policy
[ 4/16] [params] default
[ 5/16] [lock] parameter:other_param
[ 6/16] [transaction]
[ 7/16] [step] in_transaction_step_1
[ 8/16] [step] in_transaction_step_2
[ 9/16] [try]
[10/16] [step] might_raise
[11/16] [only_if] condition
[12/16] [step] optional_step
[13/16] [each] things (1/3)
[14/16] [step] in_each_step_1
[15/16] [step] in_each_step_2
(1 more steps not shown as the execution flow was stopped before reaching them)
OUTPUT
end
end
context "when the each block has no items" do
let(:things) { [] }
it "shows the each block was skipped" do
expect(output).to eq <<~OUTPUT.chomp
[ 1/16] [options] default
[ 2/16] [model] model
[ 3/16] [policy] policy
[ 4/16] [params] default
[ 5/16] [lock] parameter:other_param
[ 6/16] [transaction]
[ 7/16] [step] in_transaction_step_1
[ 8/16] [step] in_transaction_step_2
[ 9/16] [try]
[10/16] [step] might_raise
[11/16] [only_if] condition
[12/16] [step] optional_step
[13/16] [each] things (empty collection)
[14/16] [step] in_each_step_1
[15/16] [step] in_each_step_2
[16/16] [step] final_step
OUTPUT
end
end
context "when running in specs" do
context "when a successful step is flagged as being an unexpected result" do
before { result["result.policy.policy"]["spec.unexpected_result"] = true }
it "adapts its output accordingly" do
expect(output).to eq <<~OUTPUT.chomp
[ 1/16] [options] default
[ 2/16] [model] model
[ 3/16] [policy] policy <= expected to return false but got true instead
[ 4/16] [params] default
[ 5/16] [lock] parameter:other_param
[ 6/16] [transaction]
[ 7/16] [step] in_transaction_step_1
[ 8/16] [step] in_transaction_step_2
[ 9/16] [try]
[10/16] [step] might_raise
[11/16] [only_if] condition
[12/16] [step] optional_step
[13/16] [each] things (3/3)
[14/16] [step] in_each_step_1
[15/16] [step] in_each_step_2
[16/16] [step] final_step
OUTPUT
end
end
context "when a failing step is flagged as being an unexpected result" do
before do
class DummyService
def policy
false
end
end
result["result.policy.policy"]["spec.unexpected_result"] = true
end
it "adapts its output accordingly" do
expect(output).to eq <<~OUTPUT.chomp
[ 1/16] [options] default
[ 2/16] [model] model
[ 3/16] [policy] policy <= expected to return true but got false instead
(13 more steps not shown as the execution flow was stopped before reaching them)
OUTPUT
end
end
end
end
describe "#error" do
subject(:error) { inspector.error }
context "when there are no errors" do
it "returns nothing" do
expect(error).to be_blank
end
end
context "when the model step is failing" do
context "when the model is missing" do
before do
class DummyService
def fetch_model
false
end
end
end
it "returns an error related to the model" do
expect(error).to match(/Model not found/)
end
end
context "when an exception occurs inside the model step" do
before do
class DummyService
def fetch_model
raise "BOOM"
end
end
end
it "returns an error related to the exception" do
expect(error).to match(/BOOM \([^(]*RuntimeError[^)]*\)/)
end
end
context "when the model has errors" do
before do
class DummyService
def fetch_model
OpenStruct.new(
has_changes_to_save?: true,
invalid?: true,
errors: ActiveModel::Errors.new(nil),
)
end
end
end
it "returns an error related to the model" do
expect(error).to match(/ActiveModel::Errors \[\]/)
end
end
end
context "when the params step is failing" do
let(:parameter) { nil }
it "returns an error related to the contract" do
expect(error).to match(/ActiveModel::Error attribute=parameter, type=blank, options={}/)
end
it "returns the provided parameters" do
# first option is for ruby 3.3 and the other for 3.4
expect(error).to match(/{"parameter"=>nil, "other_param"=>nil}/).or match(
/{"parameter" => nil, "other_param" => nil}/,
)
end
end
context "when the policy step is failing" do
before do
class DummyService
def policy
false
end
end
end
context "when there is no reason provided" do
it "returns nothing" do
expect(error).to be_blank
end
end
context "when a reason is provided" do
before { result["result.policy.policy"][:reason] = "failed" }
it "returns the reason" do
expect(error).to eq "failed"
end
end
end
context "when a common step is failing" do
before { result["result.step.final_step"].fail(error: "my error") }
it "returns an error related to the step" do
expect(error).to eq("my error")
end
end
context "when an exception occurred inside the 'try' block" do
before do
class DummyService
def might_raise
raise "BOOM"
end
end
end
it "returns an error related to the exception" do
expect(error).to match(/BOOM \([^(]*RuntimeError[^)]*\)/)
end
end
context "when the lock step is failing" do
before { allow(DistributedMutex).to receive(:synchronize) }
it "returns an error" do
expect(error).to eq("Lock 'parameter:other_param' was not acquired.")
end
end
end
describe "#inspect" do
let(:parameter) { nil }
it "outputs the service class name, the steps results and the specific error" do
# first option is for ruby 3.3 and the other for 3.4
# the only difference between them is in the last line where the hash
# arrows are surrounded with spaces
expect(inspector.inspect.gsub(%r{ \(\d+\.\d+ ms\)}, "").gsub(/\e\[\d+(;\d+)?m/, "")).to eq(
<<~OUTPUT,
Inspecting DummyService result object:
[ 1/16] [options] default
[ 2/16] [model] model
[ 3/16] [policy] policy
[ 4/16] [params] default
(12 more steps not shown as the execution flow was stopped before reaching them)
Why it failed:
#<ActiveModel::Errors [#<ActiveModel::Error attribute=parameter, type=blank, options={}>]>
Provided parameters: {"parameter"=>nil, "other_param"=>nil}
OUTPUT
).or eq(<<~OUTPUT)
Inspecting DummyService result object:
[ 1/16] [options] default
[ 2/16] [model] model
[ 3/16] [policy] policy
[ 4/16] [params] default
(12 more steps not shown as the execution flow was stopped before reaching them)
Why it failed:
#<ActiveModel::Errors [#<ActiveModel::Error attribute=parameter, type=blank, options={}>]>
Provided parameters: {"parameter" => nil, "other_param" => nil}
OUTPUT
end
end
end