Skip to content

Instantly share code, notes, and snippets.

@tsnow
Last active August 29, 2015 13:56
Show Gist options
  • Save tsnow/9182426 to your computer and use it in GitHub Desktop.
Save tsnow/9182426 to your computer and use it in GitHub Desktop.
Driver info suggestions
- # if it's a Fleet provider but not virtual provider
- # refactor: have a flag on provider that indicates that there is additional info in Fleet database
- update_driver(params) if (provider.fleet_provider?(booking_channel.bit_mask) && driver && need_update_driver?(params))
-
- set_driver_tags(params)
-
+ info = RideDriverInfo.fetch(self,params)
+ params[:driver_phone_number] = info.driver_phone_number
+ params[:can_call_driver] = info.can_call_driver
+ params[:driver_photo_enabled] = info.driver_photo_enabled
Any time you're passing the same argument to many methods within a class,
you probably have a class that you're missing which should be wrapping those values.
With these changes, I can easily see where the values are being updated, and it's only at one level.
and RideDriverInfo can be read-only for the params hash.
we can seperate the part which looks at the hash from the part which updates it,
which is a big reduction in complexity.
# Any time you're passing the same argument to many methods within a class,
# you probably have a class that you're missing which should be wrapping those values.
#
# This wraps the params[:driver_phone_number],
# but also the behaviour which depends on that - update_driver_from_fleet.
#
# It also encapsulates the check_fleet_database feature flag access, so that ride
# need not care about that either.
class RideDriverInfo
def self.fetch(ride,params)
new(ride,params).info
end
# This is the last method you'll have to look at which uses a 'params' hash.
# They're nice syntax, but limit their parsing to one level, or one method,
# or raise an error when they are supposed to have a fixed set of keys.
#
# You can also use the parameter defaults to show what keys and values should be provided.
#
# FWIW, I do the many-line argument thing, as well as trailing-commas, to minimize syntax's
# effect on the diffs in commits. [1]
def initialize(ride,params={:driver_phone_number => nil})
@ride = ride
@driver_phone_number = params[:driver_phone_number]
update_driver_from_fleet
@info = DriverInfo.new(
driver_phone_number,
driver_photo_enabled,
can_call_driver
)
end
attr_reader :info # An outvar.
def driver_photo_enabled
ride.driver_photo_feature_enabled?
end
# Favor methods and guard clauses over large if statements with variables.
# It saves one line here, but reduces the duplicate assign.
# Was:
# if params[:driver_phone_number].blank?
# can_call_driver = false
# else
# can_call_driver = ride.can_call_driver?(params[:driver_phone_number])
# end
def can_call_driver
return false if driver_phone_number.blank?
ride.can_call_driver?(driver_phone_number)
end
#private
attr_reader :ride
attr_reader :driver_phone_number
# Judgement call: it's a toss up whether it's nicer to have
# the label of the field here for quick reference,
# or to have all accesses 'encapsulated' by a class.
# This seemed a happy medium.
def enabled?
# could be:
# ride.provider.provider_feature_enabled?('check_fleet_database', ride.booking_channel.bit_mask)
# ride.provider.fleet_provider?(ride.booking_channel.bit_mask)
# OR
# ride.provider_feature_enabled?('check_fleet_database') # see below.
"Toggle via 'check_fleet_database'"
ProviderFeatures[ride].fleet_provider?
end
# The NullObject pattern is a great way to avoid returning nils,
# and therefore to avoid causing their associated NoMethodError
# The null object still needs to be checked for in this instance (see self.driver_from_fleet)
# but that's because we don't have any default.
class NoDriver < Hash
end
# Wrap external calls with your own interface.
# 1. You won't have to change callers if they change. Here for instance,
# if response['driver']['phone'] changes, there's one place where {:driver_phone_number} needs
# to be updated.
# 2. You can test the service much more directly, i.e. without writing to the db or firing ride events;
# in both the console, and your unit tests.
# You can have one very small set of unit tests on RideDriverInfo
# with VCR cassettes to test the calls to fleet magic,
# then a seperate unit test for RideDriverInfo to test the params behaviour.
# That one can mock out the behaviour of this one method
# (since you're now dictating the interface) using mocha.
# Finally, your tests for Ride#event_assigned can mock out all of the RideDriverInfo.call,
# to just return an appropriate DriverInfo, to check that db values are assigned correctly.
def self.driver_from_fleet(ride)
request = {
:provider_keyname => ride.provider.key_name,
:driver_code => ride.driver.code,
}
driver = FleetMagicClient.show_driver(request)
return NoDriver.new unless driver.response?
{
:driver_phone_number => driver.response['driver']['phone'],
}
end
def need_update_driver?
driver_phone_number.blank?
end
# By using an object instance, we can wrap all these accesses and params accesses,
# and by using command methods,
# we can have guard clauses rather than big if statements
# Compare:
# update_driver(params) if (provider.fleet_provider?(booking_channel.bit_mask) && driver && need_update_driver?(params))
def update_driver_from_fleet
return unless enabled?
return unless ride.driver
return unless need_update_driver?
driver = self.class.driver_from_fleet(ride)
return if NoDriver === driver
@driver_phone_number = driver[:driver_phone_number]
end
end
## [1]
# Which of these is it easier to see the change in?
# This:
# - @info = DriverInfo.new(driver_phone_number, driver_photo_enabled, can_call_driver)
# + @info = DriverInfo.new(driver_phone_number, driver_photo_enabled, can_call_driver, can_message_driver)
# vs
# @info = DriverInfo.new(
# driver_phone_number,
# driver_photo_enabled,
# - can_call_driver)
# + can_call_driver,
# + can_message_driver)
#
# And:
# {
# :provider_keyname => ride.provider.key_name,
# - :driver_code => ride.driver.code
# + :driver_code => ride.driver.code,
# + :driver_name => ride.driver.name
# }
# vs
# {
# :provider_keyname => ride.provider.key_name,
# :driver_code => ride.driver.code,
# + :driver_name => ride.driver.name,
# }
# Similar to the Law of Demeter, unit tests shouldn't have to mock more than the
# class's direct collaborators. The way this is achieved is with telescoping tests.
# Take this test case. This tests ride's event_assigned method, but mocks:
# Ride#event_assigned(params) -...> Ride#update_driver(params) -> FleetMagicClient.show_driver -...> HTTP Call * This *
context "features for fleet providers" do
setup do
@ride = Ride.find(35)
assert @ride.provider.fleet_provider?(@ride.booking_channel.bit_mask)
end
should "try to update driver phone number if missing" do
driver = Driver.find_by_code("5")
VCR.use_cassette("fleet_call_for_driver", :record => :all) do
@ride.event_assigned({:driver_name => "Rider Nowat", :driver_id => driver.id})
# FleetMagicClient.expects(:show_driver).at_least_once.returns(true)
end
end
end
# So we unwrap and mock each message send as it passes from collaborator to subcollaborator.
# Each test which mocks a call has a corresponding test in the collaborator
# which supports that call with those arguments,
# showing that such a call will in fact work,
# but focused on the actual behaviour that the collaborating class implements.
class RideTest < ActiveSupport::TestCase
context "features for fleet providers" do
setup do
@ride = Ride.find(35)
end
context "event_assigned" do
context "driver_phone_number missing" do
setup do
RideDriverInfo.
expects(:call).
with(@ride,{
:driver_phone_number => "17037771111"
}).
returns(DriverInfo.new("17037771111", #driver_phone_number
true, #driver_photo_enabled
true #can_call_driver
)
end
should "apply updated driver phone numbers" do
driver = Driver.find_by_code("5")
@ride.event_assigned({:driver_name => "Rider Nowat", :driver_id => driver.id})
result = [
@ride.driver_photo_feature_enabled?,
@ride.can_call_driver?,
@ride.driver_phone_number,
]
good = [
true,
true,
"17037771111",
]
assert_equal(good, result)
end
end
end
end
class RideDriverInfoTest < ActiveSupport::TestCase
context "features for fleet providers" do
setup do
@ride = Ride.find(35)
assert @ride.provider.fleet_provider?(@ride.booking_channel.bit_mask)
provider = @ride.provider
end
context "RideDriverInfo.call" do
context "with driver phone number missing" do
setup do
RideDriverInfo.
expects(:driver_from_fleet).
with(@ride).
returns({
:driver_phone_number => "17037771111",
})
end
should "pass along updated driver phone number" do
info = RideDriverInfo.call(@ride, {
:driver_id => 5,
:driver_name => "Rider Nowat",
})
good = DriverInfo.new(
"17037771111",
true,
true
)
assert_equal(good, info)
end
end
end
context "RideDriverInfo.driver_from_fleet" do
# TODO: This could even go down a level further, and mock FleetMagicClient,
# then a seperate FleetMagicClient test could be made which uses
# the VCR cassette, instead of here.
should "receive a successful response from the server" do
VCR.use_cassette("fleet_call_for_driver", :record => :all) do
info = RideDriverInfo.driver_from_fleet(@ride)
good = {
:driver_phone_number => "17037771111",
}
assert_equal(good, info)
end
end
end
end
end
# Just a data object.
class DriverInfo < Struct.new(
:driver_phone_number,
:driver_photo_enabled,
:can_call_driver,
)
end
# How has no one made this, given how many instances there are in Ride?
class Ride < ActiveRecord::Base
def provider_feature_enabled?(key, bit_mask=booking_channel.bit_mask)
provider.provider_feature_enabled?(key,bit_mask)
end
end
# ride.provider_feature_enabled?('check_fleet_database')
# ride.provider_feature_enabled?('bounce_payment')
# It'd be even better if all this logic were in a class you could just ask.
# ProviderFeatures[ride].fleet_provider?
# ProviderFeatures[ride].bounce_provider?
# ProviderFeatures[sr,search_context].auto_best_fleet_select?
# ProviderFeatures[provider,bit_mask].booking_fee?
require 'forwardable'
class ProviderFeatures
extend Forwardable
def self.[](obj,context=nil)
case obj
when Ride then
new(obj.provider,obj.booking_channel.bit_mask)
when ProviderSearchResult then
new(obj.provider,context.booking_channel_bit_mask)
when Provider then
new(obj, context)
else
raise ArgumentError.new("ProviderFeatures[] does not understand #{obj} nor #{context}")
end
end
def initialize(provider,bit_mask)
@provider = provider
@bit_mask = bit_mask
end
attr_reader :bit_mask
def_delgator :@provider, :provider_feature_enabled?
def auto_best_fleet_select?
self.provider_feature_enabled?('auto_best_fleet_select', bit_mask)
end
def bounce_provider?
self.provider_feature_enabled?('bounce_payment', bit_mask)
end
def booking_fee?
self.provider_feature_enabled?('booking_fee', bit_mask)
end
def booking_fee_waived?
self.provider_feature_enabled?('booking_fee_waived', bit_mask)
end
def calling_enabled?
self.provider_feature_enabled?('call_driver', bit_mask)
end
def driver_photo_enabled?
self.provider_feature_enabled?('driver_photo', bit_mask)
end
def require_cc?
self.provider_feature_enabled?('require_cc', bit_mask)
end
def zero_auth_enabled?
self.provider_feature_enabled?('zero_auth', bit_mask)
end
def eta_required_for_booking?
self.provider_feature_enabled?('eta_required_for_booking', bit_mask)
end
end
Copy link

ghost commented Feb 24, 2014

Yeah, you absolutely could drop it directly onto the model. My motivation is primarily making the models smaller / making the individual modules easy to unit test. You could make a convincing argument for both sides without much effort though.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment