27
Oct

Mock testing Paypal's IPN with Rails

posted by gchatz No comments rails ruby testing

In 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:

ipn flow
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 :)