discourse/spec/lib/service/steps_inspector_spec.rb
Loïc Guitaut 719457e430 DEV: Add a try step to services
This patch adds a new step to services named `try`.

It’s useful to rescue exceptions that some steps could raise. That way,
if an exception is caught, the service will stop its execution and can
be inspected like with any other steps.

Just wrap the steps that can raise with a `try` block:
```ruby
try do
  step :step_that_can_raise
  step :another_step_that_can_raise
end
```
By default, `try` will catch any exception inheriting from
`StandardError`, but we can specify what exceptions to catch:
```ruby
try(ArgumentError, RuntimeError) do
  step :will_raise
end
```

An outcome matcher has been added: `on_exceptions`. By default it will
be executed for any exception caught by the `try` step.
Here also, we can specify what exceptions to catch:
```ruby
on_exceptions(ArgumentError, RuntimeError) do |exception|
  …
end
```

Finally, an RSpec matcher has been added:
```ruby
  it { is_expected.to fail_with_exception }
  # or
  it { is_expected.to fail_with_exception(ArgumentError) }
```
2024-11-19 12:01:07 +01:00

330 lines
8.8 KiB
Ruby
Raw 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
validates :parameter, presence: true
end
transaction do
step :in_transaction_step_1
step :in_transaction_step_2
end
try { step :might_raise }
step :final_step
end
subject(:inspector) { described_class.new(result) }
let(:parameter) { "present" }
let(:result) { DummyService.call(params: { parameter: parameter }) }
before do
class DummyService
%i[
fetch_model
policy
in_transaction_step_1
in_transaction_step_2
might_raise
final_step
].each { |name| define_method(name) { true } }
end
end
describe "#inspect" do
subject(:output) { inspector.inspect.strip }
context "when service runs without error" do
it "outputs all the steps of the service" do
expect(output).to eq <<~OUTPUT.chomp
[ 1/10] [options] 'default'
[ 2/10] [model] 'model'
[ 3/10] [policy] 'policy'
[ 4/10] [params] 'default'
[ 5/10] [transaction]
[ 6/10] [step] 'in_transaction_step_1'
[ 7/10] [step] 'in_transaction_step_2'
[ 8/10] [try]
[ 9/10] [step] 'might_raise'
[10/10] [step] 'final_step'
OUTPUT
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/10] [options] 'default'
[ 2/10] [model] 'model'
[ 3/10] [policy] 'policy'
[ 4/10] [params] 'default'
[ 5/10] [transaction]
[ 6/10] [step] 'in_transaction_step_1'
[ 7/10] [step] 'in_transaction_step_2'
[ 8/10] [try]
[ 9/10] [step] 'might_raise'
[10/10] [step] 'final_step'
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/10] [options] 'default'
[ 2/10] [model] 'model'
[ 3/10] [policy] 'policy'
[ 4/10] [params] 'default'
[ 5/10] [transaction]
[ 6/10] [step] 'in_transaction_step_1'
[ 7/10] [step] 'in_transaction_step_2'
[ 8/10] [try]
[ 9/10] [step] 'might_raise'
[10/10] [step] 'final_step'
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/10] [options] 'default'
[ 2/10] [model] 'model'
[ 3/10] [policy] 'policy'
[ 4/10] [params] 'default'
[ 5/10] [transaction]
[ 6/10] [step] 'in_transaction_step_1'
[ 7/10] [step] 'in_transaction_step_2'
[ 8/10] [try]
[ 9/10] [step] 'might_raise'
[10/10] [step] 'final_step'
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/10] [options] 'default'
[ 2/10] [model] 'model'
[ 3/10] [policy] 'policy'
[ 4/10] [params] 'default'
[ 5/10] [transaction]
[ 6/10] [step] 'in_transaction_step_1'
[ 7/10] [step] 'in_transaction_step_2'
[ 8/10] [try]
[ 9/10] [step] 'might_raise'
[10/10] [step] 'final_step'
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/10] [options] 'default'
[ 2/10] [model] 'model'
[ 3/10] [policy] 'policy'
[ 4/10] [params] 'default'
[ 5/10] [transaction]
[ 6/10] [step] 'in_transaction_step_1'
[ 7/10] [step] 'in_transaction_step_2'
[ 8/10] [try]
[ 9/10] [step] 'might_raise' 💥
[10/10] [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/10] [options] 'default'
[ 2/10] [model] 'model'
[ 3/10] [policy] 'policy' <= expected to return false but got true instead
[ 4/10] [params] 'default'
[ 5/10] [transaction]
[ 6/10] [step] 'in_transaction_step_1'
[ 7/10] [step] 'in_transaction_step_2'
[ 8/10] [try]
[ 9/10] [step] 'might_raise'
[10/10] [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/10] [options] 'default'
[ 2/10] [model] 'model'
[ 3/10] [policy] 'policy' <= expected to return true but got false instead
[ 4/10] [params] 'default'
[ 5/10] [transaction]
[ 6/10] [step] 'in_transaction_step_1'
[ 7/10] [step] 'in_transaction_step_2'
[ 8/10] [try]
[ 9/10] [step] 'might_raise'
[10/10] [step] 'final_step'
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 the model has errors" do
before do
class DummyService
def fetch_model
OpenStruct.new(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 paramaters" do
expect(error).to match(/{"parameter"=>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(/RuntimeError: BOOM/)
end
end
end
end