Since we have basic cases and success flow tested and implemented, it is time to focus on our business logic which we want to move from controller to Operation.
Our first identified logic is to care about if event is closed or is about to be closed in 1 hour. So let's add tests to check if we will not be able to add a proposal in any of those cases (we already have a test for a success flow in which an event is opened, as described by the context name).
@event.closed? && @event.closes_at < 1.hour.ago
So lets add 2 tests and update “open case”
context 'with open event' do
let!(:event) {
Event.create(
state: 'open', slug: 'Lorem', name: 'Ipsum',
url: 'http://www.c.url', closes_at: Time.current + 1.day
) }
...
end
context 'with closed event' do
let(:session_format) { instance_double(SessionFormat, id: 53) }
let!(:event) { Event.create(state: 'closed', slug: 'Lorem', name: 'Ipsum', url: 'http://www.c.url') }
it 'returns result object with an error about closed event without saving the proposal' do
expect(result[:errors]).to eq('Event is closed')
expect(result).to be_failure
end
end
context 'with event that is about to be closed' do
let(:session_format) { instance_double(SessionFormat, id: 53) }
let!(:event) {
Event.create(
state: 'open', slug: 'Lorem', name: 'Ipsum',
url: 'http://www.c.url', closes_at: Time.current + 40.minutes
) }
it 'returns result object with an error about closed event without saving the proposal' do
expect(result[:errors]).to eq('Event is closed')
expect(result).to be_failure
end
end
And check if they pass:
$ rspec spec/concepts/proposal/operation/create_spec.rb
..FF
Failures:
1) Proposal::Operation::Create with valid params with closed event returns result object with an error about closed event without saving the proposal
Failure/Error: expect(result[:errors]).to eq("Event is closed")
expected: 'Event is closed'
got: nil
2) Proposal::Operation::Create with valid params with event that is about to be closed returns result object with an error about closed event without saving the proposal
Failure/Error: expect(result[:errors]).to eq('Event is closed')
expected: 'Event is closed'
got: nil
Finished in 0.43801 seconds (files took 8.64 seconds to load)
4 examples, 2 failures
That wasn’t unexpected since we didn’t implement handling this case. Let’s do that.
(Iteration 5) Add handling “event closed or about to be closed” case.
We need to add a step checking if the event is opened, and a method to handle failure flow.
module Proposal::Operation
class Create < Trailblazer::Operation
class Present < Trailblazer::Operation
step Model(Proposal, :new)
step Contract::Build(constant: Proposal::Contract::Create)
step Contract::Validate(key: :proposal)
end
step Nested(Present)
step :event
fail :event_not_found
# step for checking if event is opened
step :event_open?
# step for handling failure
fail :event_not_open_error
step :assign_event
step Contract::Persist()
def event(ctx, params:, **)
ctx[:event] = Event.find_by(slug: params[:event_slug])
end
def assign_event(ctx, **)
ctx[:model].event = ctx[:event]
end
# we check if event.open? Method is true, and if event will be open for next hour
def event_open?(ctx, **)
ctx[:event].open? && ctx[:event].closes_at >= 1.hour.since
end
# -- bad stuff handled there --
def event_not_found(ctx, **)
ctx[:errors] = "Event not found"
end
def event_not_open_error(ctx, **)
ctx[:errors] = "Event is closed"
end
end
end
Should all of our tests be green now? Let's check:
$ rspec spec/concepts/proposal/operation/create_spec.rb
FF..
Failures:
1) Proposal::Operation::Create with valid params with open event creates a proposal assigned to event identified by slug
Failure/Error: ctx[:event].open? && ctx[:event].closes_at >= 1.hour.since
NoMethodError:
undefined method `>=' for nil:NilClass
2) Proposal::Operation::Create with valid params without event returns result object with an error about closed event without saving the proposal
Failure/Error: expect(result[:errors]).to eq("Event not found")
expected: 'Event not found'
got: 'Event is closed'
Finished in 1.37 seconds (files took 12.98 seconds to load)
4 examples, 2 failures
So what can we see, is regression - newly implemented feature, broke our previous tests? Why is that? Let's try to debug “no event” case by one of the coolest TRB features which are #wtf? method:
context 'without event' do
let(:session_format) { instance_double(SessionFormat, id: 53) }
it 'returns result object with an error about closed event without saving the proposal' do
binding.pry
expect(result[:errors]).to eq('Event not found')
expect(result).to be_failure
end
end
pry(...)> result.wtf?
`-- Proposal::Operation::Create
|-- Start.default
|-- Nested(Proposal::Operation::Create::Present)
| |-- Start.default
| |-- model.build
| |-- contract.build
| |-- contract.default.validate
| | |-- Start.default
| | |-- contract.default.params_extract
| | |-- contract.default.call
| | `-- End.success
| `-- End.success
|-- event
|-- event_not_found
|-- event_not_open_error
`-- End.failure
As you can see, #wtf? method called on our result, gave us a really explicit tree of what was called when and how it was nested. If we will track what happened, we can see that after the method event was called, fail step event_not_found was called, and then every other (in our case it's just event_not_open_error) fail step was also called.
To understand it we need to fully understand what keyword trail in Trailblazer means, and how it works. And that will be the most important knowledge from this blogpost. http://trailblazer.to/gems/operation/2.0/index.html - Flow Control section should explain basics.
But what else we have in our case? We have another fail step in the failure trail. So after calling event_not_found which calls:
ctx[:errors] = 'Event not found'
Then we call event_not_open_error which calls
ctx[:errors] = 'Event is closed'
and that overwrite our [:errors] value.
Since sometimes we need to exit from failure trail and just stop operation, and sometimes we want to run all further fail steps, TRB comes with handy method:
fail :event_not_found, fail_fast: true
After we change fail_fast value to true, whole operation logic will be stopped on the fail trail after calling failing method event_not_found in this case.
So our Operation flow will look like:
step Nested(Present)
step :event
fail :event_not_found, fail_fast: true
step :event_open?
fail :event_not_open_error
step :assign_event
step Contract::Persist()
And running the tests will result in:
$ rspec spec/concepts/proposal/operation/create_spec.rb
....
Finished in 0.48573 seconds (files took 8.7 seconds to load)
4 examples, 0 failures