BWH Payments (Next)

BWH Payments (Next)

Open in ChatGPT
Ask ChatGPT about this page
Open in Claude
Ask Claude about this page

Test with stubs

Gateway tests in BWH Payments never touch the network and never patch a transport. The controller picks its stub client under tests on its own, so every test runs the real controller code.

The pattern

  1. Call Stub<Service>Client.reset() in setUp.
  2. Drive the provider with the stub's controls.
  3. Act through the controller or the Gateway Payment Request.
  4. Assert on the outcome, and on what the stub recorded.
from frappe.tests import IntegrationTestCase

from bwh_payments.services.razorpay.stub import StubRazorpayClient


class TestRazorpay(IntegrationTestCase):
	def setUp(self):
		StubRazorpayClient.reset()
		configure_razorpay_gateway()

	def test_a_three_decimal_currency_is_charged_in_minor_units(self):
		make_razorpay_payment_request(12.345, "KWD")
		self.assertEqual(StubRazorpayClient.created_links[-1]["amount"], 12345)

	def test_an_authorised_payment_stays_pending(self):
		link_id = StubRazorpayClient.add_link("created")
		StubRazorpayClient.add_payment(link_id, "authorized")
		settings = frappe.get_single("Razorpay Gateway Settings")
		self.assertEqual(settings.get_payment_status(link_id), "Pending")

The test calls pay() on the stub, then sync_status() on the request, and the request moves from Pending to Paid.

Test the whole webhook path

To test a webhook end to end, build a werkzeug Request into frappe.local.request and call webhook.handle() directly. Sign the body with the stub's helper, so the real signature check runs:

from werkzeug.test import EnvironBuilder
from werkzeug.wrappers import Request

from bwh_payments.bwh_payments import webhook
from bwh_payments.services.razorpay.stub import build_payment_link_event, sign_payload


def post_webhook(payload: bytes, signature: str, event_id: str = "evt_1"):
	builder = EnvironBuilder(
		method="POST", path="/api/method/bwh_payments.bwh_payments.webhook.handle", data=payload
	)
	builder.headers.extend(
		{"X-Razorpay-Signature": signature, "X-Razorpay-Event-Id": event_id}
	)
	environ = builder.get_environ()
	environ["QUERY_STRING"] = "gateway=Razorpay"
	frappe.local.request = Request(environ)
	frappe.local.request_ip = "127.0.0.1"  # the rate limiter counts against it
	frappe.local.response = frappe._dict()
	return webhook.handle()


payload = build_payment_link_event(payment_request.order_ref)
post_webhook(payload, sign_payload(payload, RAZORPAY_WEBHOOK_SECRET))

Restore the original frappe.local.request in a cleanup.

What to cover

Every gateway should have tests for:

Area Cases
Amounts A 2-, a 3- and a 0-decimal currency, as sent to the provider. An unsupported currency refused before any call.
Sessions The session id is the one webhooks use. Return URLs carry reference_id.
Status Each known status. An unknown one stays Pending. Authorised versus captured.
Capture If the gateway captures: done once, for the authorised amount.
Webhooks Valid. Forged. Missing header. Wrong secret, for example the API key in place of the webhook secret. An ignored event type. An unreadable paid event.
Refunds Accepted. Refused. Nothing captured. Unknown session.
Cancel release_if_unpaid() on an open session, and on a paid one.

Fixtures to reuse

Fixture In Sets up
configure_stripe_gateway, make_payment_request test_gateway_payment_request.py Stripe settings, profile and a request
configure_razorpay_gateway test_razorpay_gateway_settings.py Razorpay settings and profile

Test an app that takes payments

bwh_payments.testing gives your app's tests a gateway that keeps its state in memory. Your tests run the real payment request, refund and webhook code, and never name a provider.

from bwh_payments import testing as payment_testing
from bwh_payments.api import create_payment, refund


class TestOrderPayment(IntegrationTestCase):
	@classmethod
	def setUpClass(cls):
		super().setUpClass()
		cls.commits = payment_testing.without_commits()
		cls.commits.__enter__()
		cls.gateway = payment_testing.configure_test_gateway()

	@classmethod
	def tearDownClass(cls):
		cls.commits.__exit__(None, None, None)  # before the rollback, which is a no-op inside it
		super().tearDownClass()

	def setUp(self):
		payment_testing.reset()

	def test_a_paid_order_is_fulfilled(self):
		payment_request = create_payment("Sales Order", self.order.name, 100, "INR", self.gateway)

		payment_testing.pay(payment_request.name)  # on_payment_authorized runs

		self.assertEqual(frappe.db.get_value("Sales Order", self.order.name, "status"), "Paid")
Helper Does
configure_test_gateway(name="Test Gateway") Enables a Payment Gateway Profile on the in-memory gateway and returns its name
reset() Forgets every payment and refund. Call it in setUp.
pay(payment_request) The shopper pays, and the payment is confirmed
set_next_refund_status(status) How the gateway answers the next refund: Pending, Processed or Failed (refused)
set_refund_status(refund, status) The gateway now reports a refund as Processed or Failed, for sync_refunds
send_refund_webhook(refund, status) Posts a signed refund webhook for a Gateway Refund
send_external_refund_webhook(payment_request, amount, status="Processed") Posts a refund raised on the gateway's dashboard
without_commits() Turns commits into no-ops, so the class rollback also undoes refunds and webhook logs
remove_test_gateway(name="Test Gateway") Deletes the test gateway's payments, refunds and logs, if you do not use without_commits

Run the tests

bench --site test_site set-config allow_tests true
bench --site test_site run-tests --app bwh_payments
bench --site test_site run-tests --module bwh_payments.tests.test_razorpay_webhook

Tests inside a doctype folder can set IGNORE_TEST_RECORD_DEPENDENCIES to avoid loading ERPNext test records. Don't set it outside a doctype folder: Frappe raises NotImplementedError there.

Next steps

Last updated 17 hours ago
Was this helpful?
Thanks!