Mock testing Paypal's IPN with Rails
posted by gchatz No commentsIn the previous article Using Paypal with Rails we showed how to implement a Paypal form using some of the Rails magic.
What’s equally important to the actual form, is, well …testing it.
Transactions are about customer’s money so you can’t rely on point and click testing.
Up until the redirection of the user to the Paypal gateway, testing can be done like usual, using the build-in mechanisms Rails provides.
What you can’t test in an automated way is Paypal’s IPN call back.
And you can’t test it because Paypal’s sandbox is unreliable. It can fire the call back after 2 seconds or 2 hours or 2 years.
So instead of relying on IPN to ping back to us, we’ll consider it as a black box and mock its input-output.
If we predict all the possible outputs of IPN we are safe in all cases.
What we need is a mock Paypal lib.
1. Creating the mock lib
In our case (using the ActiveMerchant plugin) the controller method handling the IPN is the following:
def ipn
# Create a notify object we must
notify = Paypal::Notification.new(request.raw_post)
#we must make sure this transaction id is not allready completed
if !Trans.count("*", :conditions => ["paypal_transaction_id = ?", notify.transaction_id]).zero?
# do some logging here...
end
if notify.acknowledge
begin
if notify.complete?
#transaction complete.. add your business logic here
else
#Reason to be suspicious
end
rescue => e
#Houston we have a bug
ensure
#make sure we logged everything we must
end
else #transaction was not acknowledged
# another reason to be suspicious
end
render :nothing => true
end
And in flow terms:

Flowchart created with Gliffy
What we need to mock are the decisions that come from IPN:
- Acknowledgment. (notify.acknowledge)
- Completion. (notifiy.complete?)
The Paypal notification object that comes with ActiveMerchant is actually simple:
require 'net/http'
module ActiveMerchant #:nodoc:
module Billing #:nodoc:
module Integrations #:nodoc:
module Paypal
class Notification < ActiveMerchant::Billing::Integrations::Notification
include PostsData
# Was the transaction complete?
def complete?
status == "Completed"
end
def received_at
Time.parse params['payment_date']
end
def status
params['payment_status']
end
# Id of this transaction (paypal number)
def transaction_id
params['txn_id']
end
# What type of transaction are we dealing with?
# "cart" "send_money" "web_accept" are possible here.
def type
params['txn_type']
end
# the money amount we received in X.2 decimal.
def gross
params['mc_gross']
end
# the markup paypal charges for the transaction
def fee
params['mc_fee']
end
# What currency have we been dealing with
def currency
params['mc_currency']
end
def item_id
params['item_number'] || params['custom']
end
# This is the invoice which you passed to paypal
def invoice
params['invoice']
end
# Was this a test transaction?
def test?
params['test_ipn'] == '1'
end
def account
params['business'] || params['receiver_email']
end
def acknowledge
payload = raw
response = ssl_post(Paypal.service_url + '?cmd=_notify-validate', payload,
'Content-Length' => "#{payload.size}",
'User-Agent' => "Active Merchant -- http://activemerchant.org"
)
raise StandardError.new("Faulty paypal result: #{response}") unless ["VERIFIED", "INVALID"].include?(response)
response == "VERIFIED"
####################
# mock this!
####################
end
end
end
end
end
end
We’ll create the same object in test/mocks/test/paypal_ipn_mock.rb.
The object code is exactly the same with one small change.
def acknowledge
params["acknowledge"] == "true"
end
2. Setting up the test environment
Next we require our object in *test/test_helper.rb"
ENV["RAILS_ENV"] = "test" require File.expand_path(File.dirname(__FILE__) + "/../config/environment") require 'test_help' class Test::Unit::TestCase # ... #... #..... require 'assertion_helpers' include Test::Unit::Assertions::ActiveRecordAssertions require 'paypal_ipn_mock' end
3. Writing the tests
The trick is simple. To toggle “acknowledge” on/off we’ll pass an extra custom parameter “acknowledged” and the mock lib will return true/false depending on that parameter.
post :ipn, @ipn_params.merge("acknowledge" => "false")
Our functional test includes a default IPN parameter list that we will manipulate in each test according to our needs.
require File.dirname(__FILE__) + '/../test_helper'
require 'users_controller'
# Re-raise errors caught by the controller.
class UsersController; def rescue_action(e) raise e end; end
class UsersControllerTest < Test::Unit::TestCase
include ApplicationHelper
def setup
@controller = UsersController.new
@request = ActionController::TestRequest.new
@response = ActionController::TestResponse.new
@emails = ActionMailer::Base.deliveries
@emails.clear
@trans_id = "16F08736TA389152H"
@ipn_params = {"payment_date" => "04:33:33 Oct 13.2007+PDT" ,
"txn_type" => "web_accept",
"last_name" => "User",
"residence_country" => "US",
"item_name" => "FWJ - 3 Credits",
"payment_gross" => "180.00",
"mc_currency" => "USD",
"business" => replace_this_with_your_account,
"payment_type" => "instant",
"verify_sign" => "AZQLcOZ7B.YM2m-QDAXOrQQcLFYuA0N0XoC3zadaGhkGNF2nlRWmpzlI",
"payer_status" => "verified",
"test_ipn" => "1",
"tax" => "0.00",
"payer_email" => replace_this_with_the_payers_email ,
"txn_id" => @trans_id,
"quantity" => "1",
"receiver_email" => replace_this_with_the_recievers_email,
"first_name" => "Test",
"invoice" => nil,
"payer_id" => replace_with_payers_id,
"receiver_id" => replace_with_recievers_id,
"item_number" => "3",
"payment_status" => "Completed",
"payment_fee" => "5.52",
"mc_fee" => "5.52",
"shipping" => "0.00",
"mc_gross" => "180.00",
"custom" => "3",
"charset" => "windows-1252",
"notify_version" => "2.4"
}
end
The best way to create the @ipn_params hash is to go through the payment procedure once and grab it from the development log.
And finally the tests:
##################
# I P N
##################
def test_should_log_error_and_log_transaction_if_ipn_not_acknowledged
#force ipn to return acknowledge as false
post :ipn, @ipn_params.merge("acknowledge" => "false")
assert_response 403
assert !@emails.empty?
email = @emails.first
#application specific tests
assert_equal email.to.sort, ["some@email.com"].sort
assert !Trans.find_by_paypal_transaction_id(@trans_id)
end
def test_should_log_error_and_log_transaction_if_ipn_not_completed
post :ipn, @ipn_params.merge("acknowledge" => "true", "payment_status" => "Cancelled")
assert_response 403
#some more application specific tests
end
# add tests for duplicate transaction identification, etc
As a TODO , all other statuses of IPN (“Refunded”, “Cancelled”) should be logged and handled somehow.
And remember, testing makes you sleep at night :)
