From bdbb38ecf34a4a318a9e138350b57e74566f5145 Mon Sep 17 00:00:00 2001 From: rafaeljohn9 Date: Sun, 18 Jan 2026 14:37:57 +0300 Subject: [PATCH 01/23] feat (account balance): added async support in account balance API. --- mpesakit/account_balance/__init__.py | 3 +- mpesakit/account_balance/account_balance.py | 42 ++++++++- .../account_balance/test_account_balance.py | 93 ++++++++++++++++++- 3 files changed, 130 insertions(+), 8 deletions(-) diff --git a/mpesakit/account_balance/__init__.py b/mpesakit/account_balance/__init__.py index f57f9b7..ab9ce0c 100644 --- a/mpesakit/account_balance/__init__.py +++ b/mpesakit/account_balance/__init__.py @@ -1,3 +1,4 @@ +from .account_balance import AccountBalance, AsyncAccountBalance from .schemas import ( AccountBalanceIdentifierType, AccountBalanceRequest, @@ -7,9 +8,9 @@ AccountBalanceTimeoutCallback, AccountBalanceTimeoutCallbackResponse, ) -from .account_balance import AccountBalance __all__ = [ + "AsyncAccountBalance", "AccountBalance", "AccountBalanceRequest", "AccountBalanceResponse", diff --git a/mpesakit/account_balance/account_balance.py b/mpesakit/account_balance/account_balance.py index 93964fc..cb145d7 100644 --- a/mpesakit/account_balance/account_balance.py +++ b/mpesakit/account_balance/account_balance.py @@ -5,8 +5,9 @@ """ from pydantic import BaseModel, ConfigDict -from mpesakit.auth import TokenManager -from mpesakit.http_client import HttpClient + +from mpesakit.auth import AsyncTokenManager, TokenManager +from mpesakit.http_client import AsyncHttpClient, HttpClient from .schemas import ( AccountBalanceRequest, @@ -43,5 +44,40 @@ def query(self, request: AccountBalanceRequest) -> AccountBalanceResponse: "Authorization": f"Bearer {self.token_manager.get_token()}", "Content-Type": "application/json", } - response_data = self.http_client.post(url, json=request.model_dump(by_alias=True), headers=headers) + response_data = self.http_client.post( + url, json=request.model_dump(by_alias=True), headers=headers + ) + return AccountBalanceResponse(**response_data) + + +class AsyncAccountBalance(BaseModel): + """Represents the async Account Balance API client for M-Pesa operations. + + Attributes: + http_client (AsyncHttpClient): Async HTTP client for making requests to the M-Pesa API. + token_manager (AsyncTokenManager): Async token manager for authentication. + """ + + http_client: AsyncHttpClient + token_manager: AsyncTokenManager + + model_config = ConfigDict(arbitrary_types_allowed=True) + + async def query(self, request: AccountBalanceRequest) -> AccountBalanceResponse: + """Queries the account balance asynchronously. + + Args: + request (AccountBalanceRequest): The account balance query request. + + Returns: + AccountBalanceResponse: Response from the M-Pesa API. + """ + url = "/mpesa/accountbalance/v1/query" + headers = { + "Authorization": f"Bearer {await self.token_manager.get_token()}", + "Content-Type": "application/json", + } + response_data = await self.http_client.post( + url, json=request.model_dump(by_alias=True), headers=headers + ) return AccountBalanceResponse(**response_data) diff --git a/tests/unit/account_balance/test_account_balance.py b/tests/unit/account_balance/test_account_balance.py index bd42c12..fac33af 100644 --- a/tests/unit/account_balance/test_account_balance.py +++ b/tests/unit/account_balance/test_account_balance.py @@ -3,19 +3,21 @@ This module tests the AccountBalance class and its methods for querying account balance. """ -import pytest from unittest.mock import MagicMock -from mpesakit.auth import TokenManager -from mpesakit.http_client import HttpClient + +import pytest from mpesakit.account_balance import ( - AccountBalanceIdentifierType, AccountBalance, + AccountBalanceIdentifierType, AccountBalanceRequest, AccountBalanceResponse, AccountBalanceResultCallback, AccountBalanceTimeoutCallback, + AsyncAccountBalance, ) +from mpesakit.auth import AsyncTokenManager, TokenManager +from mpesakit.http_client import AsyncHttpClient, HttpClient @pytest.fixture @@ -191,6 +193,7 @@ def test_timeout_callback_parsing(): assert callback.Result.OriginatorConversationID == "16917-22577599-3" assert callback.Result.ConversationID == "AG_20200206_00005e091a8ec6b9eac5" + def test_query_handles_string_response_code(account_balance, mock_http_client): """Ensure that ResponseCode as a string does not raise TypeError when checking is_successful.""" request = valid_account_balance_request() @@ -218,3 +221,85 @@ def test_query_handles_string_response_code(account_balance, mock_http_client): # should not raise and should consider "1" a failure assert response_fail.is_successful() is False assert response_fail.ResponseCode == "1" + + +@pytest.fixture +def mock_async_token_manager(): + """Mock AsyncTokenManager for testing.""" + mock = MagicMock(spec=AsyncTokenManager) + mock.get_token.return_value = "test_async_token" + return mock + + +@pytest.fixture +def mock_async_http_client(): + """Mock AsyncHttpClient for testing.""" + return MagicMock(spec=AsyncHttpClient) + + +@pytest.fixture +def async_account_balance(mock_async_http_client, mock_async_token_manager): + """Fixture to create an AsyncAccountBalance instance with mocked dependencies.""" + return AsyncAccountBalance( + http_client=mock_async_http_client, token_manager=mock_async_token_manager + ) + + +@pytest.mark.asyncio +async def test_async_query_returns_acknowledgement( + async_account_balance, mock_async_http_client +): + """Test that async query returns only the acknowledgement response, not the account balance.""" + request = valid_account_balance_request() + response_data = { + "ConversationID": "AG_20170717_00006c6f7f5b8b6b1a62", + "OriginatorConversationID": "12345-67890-1", + "ResponseCode": "0", + "ResponseDescription": "Accept the service request successfully.", + } + mock_async_http_client.post.return_value = response_data + + response = await async_account_balance.query(request) + + assert response.is_successful() is True + assert isinstance(response, AccountBalanceResponse) + assert response.ConversationID == response_data["ConversationID"] + assert ( + response.OriginatorConversationID == response_data["OriginatorConversationID"] + ) + assert response.ResponseCode == response_data["ResponseCode"] + assert response.ResponseDescription == response_data["ResponseDescription"] + + mock_async_http_client.post.assert_called_once() + args, kwargs = mock_async_http_client.post.call_args + assert args[0] == "/mpesa/accountbalance/v1/query" + assert kwargs["headers"]["Authorization"] == "Bearer test_async_token" + assert kwargs["headers"]["Content-Type"] == "application/json" + + +@pytest.mark.asyncio +async def test_async_query_handles_string_response_code( + async_account_balance, mock_async_http_client +): + """Ensure that ResponseCode as a string does not raise TypeError when checking is_successful in async.""" + request = valid_account_balance_request() + response_data_success = { + "ConversationID": "AG_20170717_00006c6f7f5b8b6b1a62", + "OriginatorConversationID": "12345-67890-1", + "ResponseCode": "0", + "ResponseDescription": "Accept the service request successfully.", + } + mock_async_http_client.post.return_value = response_data_success + + response = await async_account_balance.query(request) + assert response.is_successful() is True + assert response.ResponseCode == "0" + + response_data_fail = response_data_success.copy() + response_data_fail["ResponseCode"] = "1" + response_data_fail["ResponseDescription"] = "Failure." + mock_async_http_client.post.return_value = response_data_fail + + response_fail = await async_account_balance.query(request) + assert response_fail.is_successful() is False + assert response_fail.ResponseCode == "1" From b58363179aa9c9d873587f647fc519b40715f80c Mon Sep 17 00:00:00 2001 From: rafaeljohn9 Date: Sun, 18 Jan 2026 14:38:56 +0300 Subject: [PATCH 02/23] feat (B2B Express Checkout): added async support in B2B Express Checkout API with unit tests. --- mpesakit/b2b_express_checkout/__init__.py | 8 +- .../b2b_express_checkout.py | 40 ++++++++- .../test_b2b_express_checkout.py | 87 +++++++++++++++++-- 3 files changed, 124 insertions(+), 11 deletions(-) diff --git a/mpesakit/b2b_express_checkout/__init__.py b/mpesakit/b2b_express_checkout/__init__.py index cd7b46d..e7c112d 100644 --- a/mpesakit/b2b_express_checkout/__init__.py +++ b/mpesakit/b2b_express_checkout/__init__.py @@ -1,13 +1,13 @@ +from .b2b_express_checkout import AsyncB2BExpressCheckout, B2BExpressCheckout from .schemas import ( + B2BExpressCallbackResponse, + B2BExpressCheckoutCallback, B2BExpressCheckoutRequest, B2BExpressCheckoutResponse, - B2BExpressCheckoutCallback, - B2BExpressCallbackResponse, ) -from .b2b_express_checkout import B2BExpressCheckout - __all__ = [ + "AsyncB2BExpressCheckout", "B2BExpressCheckout", "B2BExpressCheckoutRequest", "B2BExpressCheckoutResponse", diff --git a/mpesakit/b2b_express_checkout/b2b_express_checkout.py b/mpesakit/b2b_express_checkout/b2b_express_checkout.py index 524489a..7440eab 100644 --- a/mpesakit/b2b_express_checkout/b2b_express_checkout.py +++ b/mpesakit/b2b_express_checkout/b2b_express_checkout.py @@ -5,8 +5,9 @@ """ from pydantic import BaseModel, ConfigDict -from mpesakit.auth import TokenManager -from mpesakit.http_client import HttpClient + +from mpesakit.auth import AsyncTokenManager, TokenManager +from mpesakit.http_client import AsyncHttpClient, HttpClient from .schemas import ( B2BExpressCheckoutRequest, @@ -49,3 +50,38 @@ def ussd_push( url, json=request.model_dump(mode="json"), headers=headers ) return B2BExpressCheckoutResponse(**response_data) + + +class AsyncB2BExpressCheckout(BaseModel): + """Represents the async B2B Express Checkout API client for M-Pesa operations. + + Attributes: + http_client (AsyncHttpClient): Async HTTP client for making requests to the M-Pesa API. + token_manager (AsyncTokenManager): Async token manager for authentication. + """ + + http_client: AsyncHttpClient + token_manager: AsyncTokenManager + + model_config = ConfigDict(arbitrary_types_allowed=True) + + async def ussd_push( + self, request: B2BExpressCheckoutRequest + ) -> B2BExpressCheckoutResponse: + """Initiates a B2B Express Checkout USSD Push transaction asynchronously. + + Args: + request (B2BExpressCheckoutRequest): The B2B Express Checkout request data. + + Returns: + B2BExpressCheckoutResponse: Response from the M-Pesa API. + """ + url = "/v1/ussdpush/get-msisdn" + headers = { + "Authorization": f"Bearer {await self.token_manager.get_token()}", + "Content-Type": "application/json", + } + response_data = await self.http_client.post( + url, json=request.model_dump(mode="json"), headers=headers + ) + return B2BExpressCheckoutResponse(**response_data) diff --git a/tests/unit/b2b_express_checkout/test_b2b_express_checkout.py b/tests/unit/b2b_express_checkout/test_b2b_express_checkout.py index 638ac7d..50c87a2 100644 --- a/tests/unit/b2b_express_checkout/test_b2b_express_checkout.py +++ b/tests/unit/b2b_express_checkout/test_b2b_express_checkout.py @@ -4,18 +4,20 @@ process responses correctly, and manage callback/error cases. """ -import pytest from unittest.mock import MagicMock -from mpesakit.auth import TokenManager -from mpesakit.http_client import HttpClient +import pytest + +from mpesakit.auth import AsyncTokenManager, TokenManager from mpesakit.b2b_express_checkout import ( + AsyncB2BExpressCheckout, + B2BExpressCallbackResponse, B2BExpressCheckout, + B2BExpressCheckoutCallback, B2BExpressCheckoutRequest, B2BExpressCheckoutResponse, - B2BExpressCheckoutCallback, - B2BExpressCallbackResponse, ) +from mpesakit.http_client import AsyncHttpClient, HttpClient @pytest.fixture @@ -120,6 +122,7 @@ def test_b2b_express_callback_response(): assert resp.ResultCode == 0 assert "Callback received successfully" in resp.ResultDesc + def test_b2b_express_callback_resultcode_as_string(): """Ensure resultCode as a string doesn't cause comparison/type errors in is_successful().""" payload = { @@ -133,3 +136,77 @@ def test_b2b_express_callback_resultcode_as_string(): # Should treat "0" (string) as success and not raise a TypeError when comparing types. assert callback.resultCode == "0" assert callback.is_successful() is True + + +@pytest.fixture +def mock_async_token_manager(): + """Mock AsyncTokenManager to return a fixed token.""" + mock = MagicMock(spec=AsyncTokenManager) + mock.get_token.return_value = "async_test_token" + return mock + + +@pytest.fixture +def mock_async_http_client(): + """Mock AsyncHttpClient to simulate async HTTP requests.""" + return MagicMock(spec=AsyncHttpClient) + + +@pytest.fixture +def async_b2b_express_checkout(mock_async_http_client, mock_async_token_manager): + """Fixture to create an AsyncB2BExpressCheckout instance with mocked dependencies.""" + return AsyncB2BExpressCheckout( + http_client=mock_async_http_client, token_manager=mock_async_token_manager + ) + + +@pytest.mark.asyncio +async def test_async_ussd_push_acknowledged( + async_b2b_express_checkout, mock_async_http_client, mock_async_token_manager +): + """Test that async USSD push request is acknowledged, not finalized.""" + request = valid_b2b_express_checkout_request() + response_data = { + "code": "0", + "status": "USSD Initiated Successfully", + } + mock_async_http_client.post.return_value = response_data + mock_async_token_manager.get_token.return_value = "async_test_token" + + response = await async_b2b_express_checkout.ussd_push(request) + + assert isinstance(response, B2BExpressCheckoutResponse) + assert response.is_successful() is True + assert response.code == response_data["code"] + assert response.status == response_data["status"] + + +@pytest.mark.asyncio +async def test_async_ussd_push_http_error( + async_b2b_express_checkout, mock_async_http_client +): + """Test handling of HTTP errors during async USSD push request.""" + request = valid_b2b_express_checkout_request() + mock_async_http_client.post.side_effect = Exception("Async HTTP error") + + with pytest.raises(Exception) as excinfo: + await async_b2b_express_checkout.ussd_push(request) + assert "Async HTTP error" in str(excinfo.value) + + +@pytest.mark.asyncio +async def test_async_ussd_push_token_manager_called( + async_b2b_express_checkout, mock_async_http_client, mock_async_token_manager +): + """Test that async token manager is properly awaited during USSD push.""" + request = valid_b2b_express_checkout_request() + response_data = { + "code": "0", + "status": "USSD Initiated Successfully", + } + mock_async_http_client.post.return_value = response_data + mock_async_token_manager.get_token.return_value = "async_test_token" + + await async_b2b_express_checkout.ussd_push(request) + + mock_async_token_manager.get_token.assert_called_once() From 5ac511cdbe3df696c08f8b18154e34fe78e12deb Mon Sep 17 00:00:00 2001 From: rafaeljohn9 Date: Sun, 18 Jan 2026 14:39:26 +0300 Subject: [PATCH 03/23] feat (B2C Account Top up): added async support in B2C Account Top Up API with unit tests. --- mpesakit/b2c_account_top_up/__init__.py | 7 +- .../b2c_account_top_up/b2c_account_top_up.py | 39 +++++++- .../test_b2c_account_top_up.py | 92 ++++++++++++++++++- 3 files changed, 128 insertions(+), 10 deletions(-) diff --git a/mpesakit/b2c_account_top_up/__init__.py b/mpesakit/b2c_account_top_up/__init__.py index 3d66f3c..3bed90d 100644 --- a/mpesakit/b2c_account_top_up/__init__.py +++ b/mpesakit/b2c_account_top_up/__init__.py @@ -1,14 +1,15 @@ +from .b2c_account_top_up import AsyncB2CAccountTopUp, B2CAccountTopUp from .schemas import ( - B2CAccountTopUpRequest, - B2CAccountTopUpResponse, B2CAccountTopUpCallback, B2CAccountTopUpCallbackResponse, + B2CAccountTopUpRequest, + B2CAccountTopUpResponse, B2CAccountTopUpTimeoutCallback, B2CAccountTopUpTimeoutCallbackResponse, ) -from .b2c_account_top_up import B2CAccountTopUp __all__ = [ + "AsyncB2CAccountTopUp", "B2CAccountTopUp", "B2CAccountTopUpRequest", "B2CAccountTopUpResponse", diff --git a/mpesakit/b2c_account_top_up/b2c_account_top_up.py b/mpesakit/b2c_account_top_up/b2c_account_top_up.py index 31fc3cc..460bc4e 100644 --- a/mpesakit/b2c_account_top_up/b2c_account_top_up.py +++ b/mpesakit/b2c_account_top_up/b2c_account_top_up.py @@ -5,8 +5,9 @@ """ from pydantic import BaseModel, ConfigDict -from mpesakit.auth import TokenManager -from mpesakit.http_client import HttpClient + +from mpesakit.auth import AsyncTokenManager, TokenManager +from mpesakit.http_client import AsyncHttpClient, HttpClient from .schemas import ( B2CAccountTopUpRequest, @@ -47,3 +48,37 @@ def topup(self, request: B2CAccountTopUpRequest) -> B2CAccountTopUpResponse: url, json=request.model_dump(mode="json"), headers=headers ) return B2CAccountTopUpResponse(**response_data) + + +class AsyncB2CAccountTopUp(BaseModel): + """Represents the async B2C Account TopUp API client for M-Pesa operations. + + Attributes: + http_client (AsyncHttpClient): Async HTTP client for making requests to the M-Pesa API. + token_manager (AsyncTokenManager): Async token manager for authentication. + """ + + http_client: AsyncHttpClient + token_manager: AsyncTokenManager + + model_config = ConfigDict(arbitrary_types_allowed=True) + + async def topup(self, request: B2CAccountTopUpRequest) -> B2CAccountTopUpResponse: + """Initiates a B2C Account TopUp transaction asynchronously. + + Args: + request (B2CAccountTopUpRequest): The B2C Account TopUp request data. + + Returns: + B2CAccountTopUpResponse: Response from the M-Pesa API. + """ + url = "/mpesa/b2b/v1/paymentrequest" + token = await self.token_manager.get_token() + headers = { + "Authorization": f"Bearer {token}", + "Content-Type": "application/json", + } + response_data = await self.http_client.post( + url, json=request.model_dump(mode="json"), headers=headers + ) + return B2CAccountTopUpResponse(**response_data) diff --git a/tests/unit/b2c_account_top_up/test_b2c_account_top_up.py b/tests/unit/b2c_account_top_up/test_b2c_account_top_up.py index 65abb0a..ac2dd39 100644 --- a/tests/unit/b2c_account_top_up/test_b2c_account_top_up.py +++ b/tests/unit/b2c_account_top_up/test_b2c_account_top_up.py @@ -4,20 +4,22 @@ process responses correctly, and manage callback/error cases. """ +from unittest.mock import AsyncMock, MagicMock + import pytest -from unittest.mock import MagicMock -from mpesakit.auth import TokenManager -from mpesakit.http_client import HttpClient +from mpesakit.auth import AsyncTokenManager, TokenManager from mpesakit.b2c_account_top_up import ( + AsyncB2CAccountTopUp, B2CAccountTopUp, - B2CAccountTopUpRequest, - B2CAccountTopUpResponse, B2CAccountTopUpCallback, B2CAccountTopUpCallbackResponse, + B2CAccountTopUpRequest, + B2CAccountTopUpResponse, B2CAccountTopUpTimeoutCallback, B2CAccountTopUpTimeoutCallbackResponse, ) +from mpesakit.http_client import AsyncHttpClient, HttpClient @pytest.fixture @@ -164,6 +166,7 @@ def test_b2c_account_topup_timeout_callback_response(): assert resp.ResultCode == 0 assert "Timeout notification received" in resp.ResultDesc + @pytest.mark.parametrize("result_code_str, expected", [("0", True), ("1", False)]) def test_b2c_account_topup_string_result_code_is_successful(result_code_str, expected): """Ensure is_successful() handles ResultCode as a string without TypeError.""" @@ -180,3 +183,82 @@ def test_b2c_account_topup_string_result_code_is_successful(result_code_str, exp callback = B2CAccountTopUpCallback(**payload) # Should not raise a TypeError when comparing string vs int inside is_successful assert callback.is_successful() is expected + + +@pytest.fixture +def mock_async_token_manager(): + """Mock AsyncTokenManager to return a fixed token.""" + mock = AsyncMock(spec=AsyncTokenManager) + mock.get_token.return_value = "test_async_token" + return mock + + +@pytest.fixture +def mock_async_http_client(): + """Mock AsyncHttpClient to simulate async HTTP requests.""" + return AsyncMock(spec=AsyncHttpClient) + + +@pytest.fixture +def async_b2c_account_topup(mock_async_http_client, mock_async_token_manager): + """Fixture to create an AsyncB2CAccountTopUp instance with mocked dependencies.""" + return AsyncB2CAccountTopUp( + http_client=mock_async_http_client, token_manager=mock_async_token_manager + ) + + +@pytest.mark.asyncio +async def test_async_topup_success( + async_b2c_account_topup, mock_async_http_client, mock_async_token_manager +): + """Test that async topup request is acknowledged and successful.""" + request = valid_b2c_account_topup_request() + response_data = { + "OriginatorConversationID": "5118-111210482-1", + "ConversationID": "AG_20230420_2010759fd5662ef6d054", + "ResponseCode": "0", + "ResponseDescription": "Accept the service request successfully.", + } + mock_async_http_client.post.return_value = response_data + mock_async_token_manager.get_token.return_value = "test_async_token" + + response = await async_b2c_account_topup.topup(request) + + assert isinstance(response, B2CAccountTopUpResponse) + assert response.is_successful() is True + assert response.ResponseCode == response_data["ResponseCode"] + + +@pytest.mark.asyncio +async def test_async_topup_http_error( + async_b2c_account_topup, mock_async_http_client, mock_async_token_manager +): + """Test handling of HTTP errors during async topup request.""" + request = valid_b2c_account_topup_request() + mock_async_http_client.post.side_effect = Exception("Async HTTP error") + mock_async_token_manager.get_token.return_value = "test_async_token" + + with pytest.raises(Exception) as excinfo: + await async_b2c_account_topup.topup(request) + assert "Async HTTP error" in str(excinfo.value) + + +@pytest.mark.asyncio +async def test_async_topup_token_retrieval( + async_b2c_account_topup, mock_async_http_client, mock_async_token_manager +): + """Test that async topup correctly retrieves and uses the token.""" + request = valid_b2c_account_topup_request() + response_data = { + "OriginatorConversationID": "5118-111210482-1", + "ConversationID": "AG_20230420_2010759fd5662ef6d054", + "ResponseCode": "0", + "ResponseDescription": "Accept the service request successfully.", + } + mock_async_http_client.post.return_value = response_data + mock_async_token_manager.get_token.return_value = "async_test_token_123" + + response = await async_b2c_account_topup.topup(request) + + assert response.is_successful() is True + mock_async_token_manager.get_token.assert_called_once() From ffd1f8d6453f4e4bb020fc9fc7a434f01a3ea4bd Mon Sep 17 00:00:00 2001 From: rafaeljohn9 Date: Sun, 18 Jan 2026 14:53:41 +0300 Subject: [PATCH 04/23] feat (B2C): added async support for B2C API with unit tests for testing. --- mpesakit/b2c/__init__.py | 7 +-- mpesakit/b2c/b2c.py | 42 ++++++++++++++++-- tests/unit/b2c/test_b2c.py | 87 +++++++++++++++++++++++++++++++++++--- 3 files changed, 125 insertions(+), 11 deletions(-) diff --git a/mpesakit/b2c/__init__.py b/mpesakit/b2c/__init__.py index 2b1d8a7..7611a67 100644 --- a/mpesakit/b2c/__init__.py +++ b/mpesakit/b2c/__init__.py @@ -1,16 +1,17 @@ +from .b2c import B2C, AsyncB2C from .schemas import ( B2CCommandIDType, B2CRequest, B2CResponse, - B2CResultParameter, - B2CResultMetadata, B2CResultCallback, + B2CResultMetadata, + B2CResultParameter, B2CTimeoutCallback, B2CTimeoutCallbackResponse, ) -from .b2c import B2C __all__ = [ + "AsyncB2C", "B2C", "B2CCommandIDType", "B2CRequest", diff --git a/mpesakit/b2c/b2c.py b/mpesakit/b2c/b2c.py index 681dcf0..6675024 100644 --- a/mpesakit/b2c/b2c.py +++ b/mpesakit/b2c/b2c.py @@ -5,8 +5,9 @@ """ from pydantic import BaseModel, ConfigDict -from mpesakit.auth import TokenManager -from mpesakit.http_client import HttpClient + +from mpesakit.auth import AsyncTokenManager, TokenManager +from mpesakit.http_client import AsyncHttpClient, HttpClient from .schemas import ( B2CRequest, @@ -43,5 +44,40 @@ def send_payment(self, request: B2CRequest) -> B2CResponse: "Authorization": f"Bearer {self.token_manager.get_token()}", "Content-Type": "application/json", } - response_data = self.http_client.post(url, json=request.model_dump(by_alias=True), headers=headers) + response_data = self.http_client.post( + url, json=request.model_dump(by_alias=True), headers=headers + ) + return B2CResponse(**response_data) + + +class AsyncB2C(BaseModel): + """Represents the async B2C API client for M-Pesa Business to Customer operations. + + Attributes: + http_client (AsyncHttpClient): Async HTTP client for making requests to the M-Pesa API. + token_manager (AsyncTokenManager): Async token manager for authentication. + """ + + http_client: AsyncHttpClient + token_manager: AsyncTokenManager + + model_config = ConfigDict(arbitrary_types_allowed=True) + + async def send_payment(self, request: B2CRequest) -> B2CResponse: + """Initiates a B2C payment request asynchronously. + + Args: + request (B2CRequest): The payment request details. + + Returns: + B2CResponse: Response from the M-Pesa API after payment initiation. + """ + url = "/mpesa/b2c/v3/paymentrequest" + headers = { + "Authorization": f"Bearer {await self.token_manager.get_token()}", + "Content-Type": "application/json", + } + response_data = await self.http_client.post( + url, json=request.model_dump(by_alias=True), headers=headers + ) return B2CResponse(**response_data) diff --git a/tests/unit/b2c/test_b2c.py b/tests/unit/b2c/test_b2c.py index 3e4dec6..842a54e 100644 --- a/tests/unit/b2c/test_b2c.py +++ b/tests/unit/b2c/test_b2c.py @@ -1,12 +1,13 @@ """Unit tests for the B2C functionality of the Mpesa SDK.""" -from unittest.mock import MagicMock +from unittest.mock import AsyncMock, MagicMock import pytest -from mpesakit.auth import TokenManager -from mpesakit.b2c.b2c import B2C -from mpesakit.b2c.schemas import ( +from mpesakit.auth import AsyncTokenManager, TokenManager +from mpesakit.b2c import ( + B2C, + AsyncB2C, B2CCommandIDType, B2CRequest, B2CResponse, @@ -14,7 +15,7 @@ B2CResultMetadata, B2CResultParameter, ) -from mpesakit.http_client import HttpClient +from mpesakit.http_client import AsyncHttpClient, HttpClient @pytest.fixture @@ -421,3 +422,79 @@ def test_result_callback_is_successful_negative_code(): ) callback = B2CResultCallback(Result=meta) assert callback.is_successful() is False + + +@pytest.fixture +def mock_async_token_manager(): + """Mock AsyncTokenManager to return a fixed token for testing.""" + mock = MagicMock(spec=AsyncTokenManager) + mock.get_token = AsyncMock(return_value="test_token_async") + return mock + + +@pytest.fixture +def mock_async_http_client(): + """Mock AsyncHttpClient for testing.""" + mock = MagicMock(spec=AsyncHttpClient) + mock.post = AsyncMock() + return mock + + +@pytest.fixture +def async_b2c(mock_async_http_client, mock_async_token_manager): + """Fixture to create an instance of AsyncB2C with mocked dependencies.""" + return AsyncB2C( + http_client=mock_async_http_client, token_manager=mock_async_token_manager + ) + + +@pytest.mark.asyncio +async def test_async_send_payment_success( + async_b2c, mock_async_http_client, mock_async_token_manager +): + """Test that a successful async B2C payment can be performed.""" + request = valid_b2c_request() + response_data = { + "ConversationID": "AG_20170717_00006c6f7f5b8b6b1a62", + "OriginatorConversationID": "12345-67890-1", + "ResponseCode": "0", + "ResponseDescription": "Accept the service request successfully.", + } + + mock_async_http_client.post.return_value = response_data + + response = await async_b2c.send_payment(request) + + assert isinstance(response, B2CResponse) + assert response.ConversationID == response_data["ConversationID"] + assert ( + response.OriginatorConversationID == response_data["OriginatorConversationID"] + ) + assert response.ResponseCode == response_data["ResponseCode"] + assert response.ResponseDescription == response_data["ResponseDescription"] + + +@pytest.mark.asyncio +async def test_async_send_payment_http_error( + async_b2c, mock_async_http_client, mock_async_token_manager +): + """Test that async B2C payment handles HTTP errors gracefully.""" + request = valid_b2c_request() + mock_async_token_manager.get_token.side_effect = Exception("Token error") + + with pytest.raises(Exception) as excinfo: + await async_b2c.send_payment(request) + assert "Token error" in str(excinfo.value) + + +@pytest.mark.asyncio +async def test_async_send_payment_post_error( + async_b2c, mock_async_http_client, mock_async_token_manager +): + """Test that async B2C payment handles POST request errors.""" + request = valid_b2c_request() + mock_async_http_client.post.side_effect = Exception("HTTP error") + + with pytest.raises(Exception) as excinfo: + await async_b2c.send_payment(request) + assert "HTTP error" in str(excinfo.value) From 4f9d473b863f9451ab71cda7c588caabe1afdb54 Mon Sep 17 00:00:00 2001 From: rafaeljohn9 Date: Sun, 18 Jan 2026 15:10:07 +0300 Subject: [PATCH 05/23] feat (Bill Manager): added async support for Bill Manager API and with unit tests for testing and updated unit tests name from Bill Integration to Bill Manager. --- mpesakit/bill_manager/__init__.py | 24 +- mpesakit/bill_manager/bill_manager.py | 132 ++++- .../bill_integration/test_bill_manager.py | 518 ++++++++++++++++++ .../test_bill_manager.py} | 177 +++++- 4 files changed, 817 insertions(+), 34 deletions(-) create mode 100644 tests/unit/bill_integration/test_bill_manager.py rename tests/unit/{bill_integration/test_bill_integration.py => bill_manager/test_bill_manager.py} (68%) diff --git a/mpesakit/bill_manager/__init__.py b/mpesakit/bill_manager/__init__.py index 586832e..8f5d3a5 100644 --- a/mpesakit/bill_manager/__init__.py +++ b/mpesakit/bill_manager/__init__.py @@ -1,25 +1,26 @@ +from .bill_manager import AsyncBillManager, BillManager from .schemas import ( - BillManagerOptInRequest, - BillManagerOptInResponse, - BillManagerUpdateOptInRequest, - BillManagerUpdateOptInResponse, - BillManagerSingleInvoiceRequest, - BillManagerSingleInvoiceResponse, BillManagerBulkInvoiceRequest, BillManagerBulkInvoiceResponse, - BillManagerCancelSingleInvoiceRequest, BillManagerCancelBulkInvoiceRequest, BillManagerCancelInvoiceResponse, - BillManagerPaymentNotificationRequest, - BillManagerPaymentNotificationResponse, + BillManagerCancelSingleInvoiceRequest, + BillManagerOptInRequest, + BillManagerOptInResponse, BillManagerPaymentAcknowledgmentRequest, BillManagerPaymentAcknowledgmentResponse, + BillManagerPaymentNotificationRequest, + BillManagerPaymentNotificationResponse, + BillManagerSingleInvoiceRequest, + BillManagerSingleInvoiceResponse, + BillManagerUpdateOptInRequest, + BillManagerUpdateOptInResponse, InvoiceItem, ) -from .bill_manager import BillManager - __all__ = [ + "AsyncBillManager", + "BillManager", "BillManagerOptInRequest", "BillManagerOptInResponse", "BillManagerUpdateOptInRequest", @@ -35,6 +36,5 @@ "BillManagerPaymentNotificationResponse", "BillManagerPaymentAcknowledgmentRequest", "BillManagerPaymentAcknowledgmentResponse", - "BillManager", "InvoiceItem", ] diff --git a/mpesakit/bill_manager/bill_manager.py b/mpesakit/bill_manager/bill_manager.py index 759ea0e..804e876 100644 --- a/mpesakit/bill_manager/bill_manager.py +++ b/mpesakit/bill_manager/bill_manager.py @@ -5,23 +5,25 @@ Requires a valid access token for authentication and uses the HttpClient for HTTP requests. """ -from pydantic import BaseModel, ConfigDict from typing import Optional -from mpesakit.auth import TokenManager -from mpesakit.http_client import HttpClient + +from pydantic import BaseModel, ConfigDict + +from mpesakit.auth import AsyncTokenManager, TokenManager +from mpesakit.http_client import AsyncHttpClient, HttpClient from .schemas import ( - BillManagerOptInRequest, - BillManagerOptInResponse, - BillManagerUpdateOptInRequest, - BillManagerUpdateOptInResponse, - BillManagerSingleInvoiceRequest, - BillManagerSingleInvoiceResponse, BillManagerBulkInvoiceRequest, BillManagerBulkInvoiceResponse, - BillManagerCancelSingleInvoiceRequest, BillManagerCancelBulkInvoiceRequest, BillManagerCancelInvoiceResponse, + BillManagerCancelSingleInvoiceRequest, + BillManagerOptInRequest, + BillManagerOptInResponse, + BillManagerSingleInvoiceRequest, + BillManagerSingleInvoiceResponse, + BillManagerUpdateOptInRequest, + BillManagerUpdateOptInResponse, ) @@ -131,3 +133,113 @@ def cancel_bulk_invoice( url, json=request.model_dump(mode="json"), headers=headers ) return BillManagerCancelInvoiceResponse(**response_data) + + +class AsyncBillManager(BaseModel): + """Represents the async Bill Manager API client for M-PESA operations.""" + + http_client: AsyncHttpClient + token_manager: AsyncTokenManager + app_key: Optional[str] = None + + model_config = ConfigDict(arbitrary_types_allowed=True) + + async def opt_in( + self, request: BillManagerOptInRequest + ) -> BillManagerOptInResponse: + """Onboard a paybill to Bill Manager.""" + url = "/v1/billmanager-invoice/optin" + headers = { + "Authorization": f"Bearer {await self.token_manager.get_token()}", + "Content-Type": "application/json", + } + response_data = await self.http_client.post( + url, json=request.model_dump(mode="json"), headers=headers + ) + return BillManagerOptInResponse(**response_data) + + def _ensure_app_key(self): + if self.app_key is None: + raise ValueError( + "app_key must be set for this operation. You must pass it when initializing BillManager." + ) + + async def update_opt_in( + self, request: BillManagerUpdateOptInRequest + ) -> BillManagerUpdateOptInResponse: + """Update opt-in details for Bill Manager.""" + self._ensure_app_key() + url = "/v1/billmanager-invoice/change-optin-details" + headers = { + "Authorization": f"Bearer {await self.token_manager.get_token()}", + "Content-Type": "application/json", + "appKey": f"{self.app_key}", + } + response_data = await self.http_client.post( + url, json=request.model_dump(mode="json"), headers=headers + ) + return BillManagerUpdateOptInResponse(**response_data) + + async def send_single_invoice( + self, request: BillManagerSingleInvoiceRequest + ) -> BillManagerSingleInvoiceResponse: + """Send a single invoice via Bill Manager.""" + self._ensure_app_key() + url = "/v1/billmanager-invoice/single-invoicing" + headers = { + "Authorization": f"Bearer {await self.token_manager.get_token()}", + "Content-Type": "application/json", + "appKey": f"{self.app_key}", + } + response_data = await self.http_client.post( + url, json=request.model_dump(mode="json"), headers=headers + ) + return BillManagerSingleInvoiceResponse(**response_data) + + async def send_bulk_invoice( + self, request: BillManagerBulkInvoiceRequest + ) -> BillManagerBulkInvoiceResponse: + """Send multiple invoices via Bill Manager.""" + self._ensure_app_key() + url = "/v1/billmanager-invoice/bulk-invoicing" + headers = { + "Authorization": f"Bearer {await self.token_manager.get_token()}", + "Content-Type": "application/json", + "appKey": f"{self.app_key}", + } + response_data = await self.http_client.post( + url, json=request.model_dump(mode="json"), headers=headers + ) + return BillManagerBulkInvoiceResponse(**response_data) + + async def cancel_single_invoice( + self, request: BillManagerCancelSingleInvoiceRequest + ) -> BillManagerCancelInvoiceResponse: + """Cancel a single invoice via Bill Manager.""" + self._ensure_app_key() + url = "/v1/billmanager-invoice/cancel-single-invoice" + headers = { + "Authorization": f"Bearer {await self.token_manager.get_token()}", + "Content-Type": "application/json", + "appKey": f"{self.app_key}", + } + response_data = await self.http_client.post( + url, json=request.model_dump(mode="json"), headers=headers + ) + return BillManagerCancelInvoiceResponse(**response_data) + + async def cancel_bulk_invoice( + self, request: BillManagerCancelBulkInvoiceRequest + ) -> BillManagerCancelInvoiceResponse: + """Cancel multiple invoices via Bill Manager.""" + self._ensure_app_key() + url = "/v1/billmanager-invoice/cancel-bulk-invoices" + headers = { + "Authorization": f"Bearer {await self.token_manager.get_token()}", + "Content-Type": "application/json", + "appKey": f"{self.app_key}", + } + response_data = await self.http_client.post( + url, json=request.model_dump(mode="json"), headers=headers + ) + return BillManagerCancelInvoiceResponse(**response_data) diff --git a/tests/unit/bill_integration/test_bill_manager.py b/tests/unit/bill_integration/test_bill_manager.py new file mode 100644 index 0000000..c445aa2 --- /dev/null +++ b/tests/unit/bill_integration/test_bill_manager.py @@ -0,0 +1,518 @@ +"""Unit tests for the M-Pesa SDK Bill Manager functionality. + +This module tests the Bill Manager API client, ensuring it can handle onboarding, +invoicing, cancellation, and error cases. +""" + +from datetime import datetime +from unittest.mock import AsyncMock, MagicMock + +import pytest +from pydantic import ValidationError + +from mpesakit.auth import AsyncTokenManager, TokenManager +from mpesakit.bill_manager import ( + AsyncBillManager, + BillManager, + BillManagerBulkInvoiceRequest, + BillManagerBulkInvoiceResponse, + BillManagerCancelBulkInvoiceRequest, + BillManagerCancelInvoiceResponse, + BillManagerCancelSingleInvoiceRequest, + BillManagerOptInRequest, + BillManagerOptInResponse, + BillManagerSingleInvoiceRequest, + BillManagerSingleInvoiceResponse, + BillManagerUpdateOptInRequest, + BillManagerUpdateOptInResponse, +) +from mpesakit.http_client import AsyncHttpClient, HttpClient + + +@pytest.fixture +def mock_token_manager(): + """Mock TokenManager for testing purposes.""" + mock = MagicMock(spec=TokenManager) + mock.get_token.return_value = "test_token" + return mock + + +@pytest.fixture +def mock_http_client(): + """Mock HttpClient for testing purposes.""" + return MagicMock(spec=HttpClient) + + +@pytest.fixture +def bill_manager(mock_http_client, mock_token_manager): + """Fixture to create a BillManager instance with mocked HttpClient and TokenManager.""" + return BillManager( + http_client=mock_http_client, + token_manager=mock_token_manager, + app_key="test_app_key", + ) + + +def valid_opt_in_request(): + """Creates a valid opt-in request for Bill Manager.""" + return BillManagerOptInRequest( + shortcode=718003, + email="youremail@gmail.com", + officialContact="0710123456", + sendReminders=1, + logo="image", + callbackurl="http://my.server.com/bar/callback", + ) + + +def valid_update_opt_in_request(): + """Creates a valid update opt-in request for Bill Manager.""" + return BillManagerUpdateOptInRequest( + shortcode=718003, + email="youremail@gmail.com", + officialContact="0710123456", + sendReminders=1, + logo="image", + callbackurl="http://my.server.com/bar/callback", + ) + + +def valid_single_invoice_request(): + """Creates a valid single invoice request for Bill Manager.""" + return BillManagerSingleInvoiceRequest( + externalReference="#9932340", + billedFullName="John Doe", + billedPhoneNumber="0710123456", + billedPeriod="August 2021", + invoiceName="Jentrys", + dueDate="2021-10-12", + accountReference="1ASD678H", + amount=800, + invoiceItems=[ + {"itemName": "food", "amount": 700}, + {"itemName": "water", "amount": 100}, + ], + ) + + +def valid_bulk_invoice_request(): + """Creates a valid bulk invoice request for Bill Manager.""" + return BillManagerBulkInvoiceRequest(invoices=[valid_single_invoice_request()]) + + +def valid_cancel_single_invoice_request(): + """Creates a valid cancel single invoice request for Bill Manager.""" + return BillManagerCancelSingleInvoiceRequest(externalReference="113") + + +def valid_cancel_bulk_invoice_request(): + """Creates a valid cancel bulk invoice request for Bill Manager.""" + return BillManagerCancelBulkInvoiceRequest( + invoices=[ + BillManagerCancelSingleInvoiceRequest(externalReference="113"), + BillManagerCancelSingleInvoiceRequest(externalReference="114"), + ] + ) + + +def test_opt_in_success(bill_manager, mock_http_client): + """Test successful opt-in to Bill Manager.""" + request = valid_opt_in_request() + response_data = { + "app_key": "AG_2376487236_126732989KJ", + "resmsg": "Success", + "rescode": "200", + } + mock_http_client.post.return_value = response_data + response = bill_manager.opt_in(request) + assert isinstance(response, BillManagerOptInResponse) + assert response.app_key == response_data["app_key"] + assert response.rescode == "200" + + +def test_update_opt_in_success(bill_manager, mock_http_client): + """Test successful update of opt-in settings for Bill Manager.""" + request = valid_update_opt_in_request() + response_data = { + "resmsg": "Success", + "rescode": "200", + } + mock_http_client.post.return_value = response_data + response = bill_manager.update_opt_in(request) + assert isinstance(response, BillManagerUpdateOptInResponse) + assert response.rescode == "200" + + +def test_send_single_invoice_success(bill_manager, mock_http_client): + """Test sending a single invoice via Bill Manager.""" + request = valid_single_invoice_request() + response_data = { + "Status_Message": "Invoice sent successfully", + "resmsg": "Success", + "rescode": "200", + } + mock_http_client.post.return_value = response_data + response = bill_manager.send_single_invoice(request) + assert isinstance(response, BillManagerSingleInvoiceResponse) + assert response.is_successful() is True + assert response.Status_Message == response_data["Status_Message"] + + +def test_send_bulk_invoice_success(bill_manager, mock_http_client): + """Test sending multiple invoices via Bill Manager.""" + request = valid_bulk_invoice_request() + response_data = { + "Status_Message": "Invoice sent successfully", + "resmsg": "Success", + "rescode": "200", + } + mock_http_client.post.return_value = response_data + response = bill_manager.send_bulk_invoice(request) + assert isinstance(response, BillManagerBulkInvoiceResponse) + assert response.Status_Message == response_data["Status_Message"] + + +def test_cancel_single_invoice_success(bill_manager, mock_http_client): + """Test cancelling a single invoice via Bill Manager.""" + request = valid_cancel_single_invoice_request() + response_data = { + "Status_Message": "Invoice cancelled successfully.", + "resmsg": "Success", + "rescode": "200", + "errors": [], + } + mock_http_client.post.return_value = response_data + response = bill_manager.cancel_single_invoice(request) + assert isinstance(response, BillManagerCancelInvoiceResponse) + assert response.is_successful() is True + assert response.Status_Message == response_data["Status_Message"] + + +def test_cancel_bulk_invoice_success(bill_manager, mock_http_client): + """Test cancelling multiple invoices via Bill Manager.""" + request = valid_cancel_bulk_invoice_request() + response_data = { + "Status_Message": "Invoices cancelled successfully.", + "resmsg": "Success", + "rescode": "200", + "errors": [], + } + mock_http_client.post.return_value = response_data + response = bill_manager.cancel_bulk_invoice(request) + assert isinstance(response, BillManagerCancelInvoiceResponse) + assert response.Status_Message == response_data["Status_Message"] + + +def test_bill_manager_http_error(bill_manager, mock_http_client): + """Test handling of HTTP errors when sending a single invoice.""" + request = valid_single_invoice_request() + mock_http_client.post.side_effect = Exception("HTTP error") + with pytest.raises(Exception) as excinfo: + bill_manager.send_single_invoice(request) + assert "HTTP error" in str(excinfo.value) + + +def test_app_key_required_for_invoice(mock_http_client, mock_token_manager): + """Test app_key requirement for sending a single invoice.""" + manager = BillManager( + http_client=mock_http_client, token_manager=mock_token_manager + ) + request = valid_single_invoice_request() + with pytest.raises(ValueError) as excinfo: + manager.send_single_invoice(request) + assert "app_key must be set" in str(excinfo.value) + + +@pytest.mark.parametrize( + "due_date,expected", + [ + ("2021-10-12", "2021-10-12"), + ("2021/10/12", "2021-10-12"), + ("2021-10-12 14:30", "2021-10-12 14:30:00"), + ("2021-10-12 14:30:00", "2021-10-12 14:30:00.00"), + ("2021/10/12 14:30:00", "2021-10-12 14:30:00.00"), + ("2021-10-12 14:30:00.123", "2021-10-12 14:30:00.12"), + ("2021-10-12 14:30:00.10", "2021-10-12 14:30:00.10"), + ("2025-08-19T15:33:15.376886", "2025-08-19 15:33:15.37"), + ], +) +def test_due_date_valid_formats(due_date, expected): + """Test valid dueDate formats are accepted and normalized.""" + req = { + "externalReference": "#9932340", + "billedFullName": "John Doe", + "billedPhoneNumber": "0710123456", + "billedPeriod": "August 2021", + "invoiceName": "Jentrys", + "dueDate": due_date, + "accountReference": "1ASD678H", + "amount": 800, + "invoiceItems": [{"itemName": "food", "amount": 700}], + } + result = BillManagerSingleInvoiceRequest.model_validate(req) + + # Try parsing both result.dueDate and expected to datetime objects + def parse_due_date(date_str): + # Try different formats based on input + for fmt in [ + "%Y-%m-%d", + "%Y/%m/%d", + "%Y.%m.%d", + "%Y-%m-%d %H:%M:%S.%f", + "%Y/%m/%d %H:%M:%S.%f", + "%Y.%m.%d %H:%M:%S.%f", + "%Y-%m-%d %H:%M:%S", + "%Y/%m/%d %H:%M:%S", + "%Y.%m.%d %H:%M:%S", + "%Y-%m-%dT%H:%M:%S.%f", + ]: + try: + return datetime.strptime(date_str, fmt) + except Exception: + continue + raise ValueError(f"Unrecognized date format: {date_str}") + + dt_result = parse_due_date(result.dueDate) + dt_expected = parse_due_date(expected) + # Compare up to the second and first two digits of microseconds + assert dt_result.replace( + microsecond=(dt_result.microsecond // 10000) * 10000 + ) == dt_expected.replace(microsecond=(dt_expected.microsecond // 10000) * 10000) + + +@pytest.mark.parametrize( + "due_date", + [ + "2021-13-12", # invalid month + "2021-10-32", # invalid day + "20211012", # missing separators + "2021-10-12 14:30:00:00", # extra colon + "2021-10-12 14", # missing minutes and seconds + "12-10-2021", # wrong order + "", + ], +) +def test_due_date_invalid_formats_raise(due_date): + """Test invalid dueDate formats raise ValueError.""" + req = { + "externalReference": "#9932340", + "billedFullName": "John Doe", + "billedPhoneNumber": "0710123456", + "billedPeriod": "August 2021", + "invoiceName": "Jentrys", + "dueDate": due_date, + "accountReference": "1ASD678H", + "amount": 800, + "invoiceItems": [{"itemName": "food", "amount": 700}], + } + with pytest.raises(ValidationError) as excinfo: + BillManagerSingleInvoiceRequest(**req) + assert "validation error" in str(excinfo.value) + + +def test_due_date_missing_raises(): + """Test missing dueDate raises ValueError.""" + req = { + "externalReference": "#9932340", + "billedFullName": "John Doe", + "billedPhoneNumber": "0710123456", + "billedPeriod": "August 2021", + "invoiceName": "Jentrys", + # "dueDate" missing + "accountReference": "1ASD678H", + "amount": 800, + "invoiceItems": [{"itemName": "food", "amount": 700}], + } + with pytest.raises(ValidationError) as excinfo: + BillManagerSingleInvoiceRequest.model_validate(req) + assert "dueDate is required" in str(excinfo.value) + + +def test_billed_period_invalid_raises(): + """Test invalid billedPeriod raises ValueError.""" + req = { + "externalReference": "#9932340", + "billedFullName": "John Doe", + "billedPhoneNumber": "0710123456", + "billedPeriod": "2021-08", # invalid format + "invoiceName": "Jentrys", + "dueDate": "2021-10-12", + "accountReference": "1ASD678H", + "amount": 800, + "invoiceItems": [{"itemName": "food", "amount": 700}], + } + with pytest.raises(ValueError) as excinfo: + BillManagerSingleInvoiceRequest(**req) + assert "billedPeriod" in str(excinfo.value) + + +def test_result_code_as_string_does_not_raise(bill_manager, mock_http_client): + """Ensure response.resultCode as a string does not cause type errors in is_successful.""" + request = valid_single_invoice_request() + # resultCode intentionally a string to simulate APIs that return numeric codes as strings + response_data = { + "resultCode": "0", + "Status_Message": "Invoice sent successfully", + "resmsg": "Success", + "rescode": "200", + } + mock_http_client.post.return_value = response_data + + response = bill_manager.send_single_invoice(request) + + # Calling is_successful() should not raise a TypeError from comparing str and int; + # it should return a boolean result. + is_success = response.is_successful() + assert isinstance(is_success, bool) + + +@pytest.fixture +def mock_async_token_manager(): + """Mock AsyncTokenManager for testing purposes.""" + mock = AsyncMock(spec=AsyncTokenManager) + mock.get_token.return_value = "test_async_token" + return mock + + +@pytest.fixture +def mock_async_http_client(): + """Mock AsyncHttpClient for testing purposes.""" + return AsyncMock(spec=AsyncHttpClient) + + +@pytest.fixture +def async_bill_manager(mock_async_http_client, mock_async_token_manager): + """Fixture to create an AsyncBillManager instance with mocked AsyncHttpClient and AsyncTokenManager.""" + return AsyncBillManager( + http_client=mock_async_http_client, + token_manager=mock_async_token_manager, + app_key="test_app_key", + ) + + +@pytest.mark.asyncio +async def test_async_opt_in_success(async_bill_manager, mock_async_http_client): + """Test successful async opt-in to Bill Manager.""" + request = valid_opt_in_request() + response_data = { + "app_key": "AG_2376487236_126732989KJ", + "resmsg": "Success", + "rescode": "200", + } + mock_async_http_client.post.return_value = response_data + response = await async_bill_manager.opt_in(request) + assert isinstance(response, BillManagerOptInResponse) + assert response.app_key == response_data["app_key"] + assert response.rescode == "200" + + +@pytest.mark.asyncio +async def test_async_update_opt_in_success(async_bill_manager, mock_async_http_client): + """Test successful async update of opt-in settings for Bill Manager.""" + request = valid_update_opt_in_request() + response_data = { + "resmsg": "Success", + "rescode": "200", + } + mock_async_http_client.post.return_value = response_data + response = await async_bill_manager.update_opt_in(request) + assert isinstance(response, BillManagerUpdateOptInResponse) + assert response.rescode == "200" + + +@pytest.mark.asyncio +async def test_async_send_single_invoice_success( + async_bill_manager, mock_async_http_client +): + """Test sending a single invoice asynchronously via Bill Manager.""" + request = valid_single_invoice_request() + response_data = { + "Status_Message": "Invoice sent successfully", + "resmsg": "Success", + "rescode": "200", + } + mock_async_http_client.post.return_value = response_data + response = await async_bill_manager.send_single_invoice(request) + assert isinstance(response, BillManagerSingleInvoiceResponse) + assert response.is_successful() is True + assert response.Status_Message == response_data["Status_Message"] + + +@pytest.mark.asyncio +async def test_async_send_bulk_invoice_success( + async_bill_manager, mock_async_http_client +): + """Test sending multiple invoices asynchronously via Bill Manager.""" + request = valid_bulk_invoice_request() + response_data = { + "Status_Message": "Invoice sent successfully", + "resmsg": "Success", + "rescode": "200", + } + mock_async_http_client.post.return_value = response_data + response = await async_bill_manager.send_bulk_invoice(request) + assert isinstance(response, BillManagerBulkInvoiceResponse) + assert response.Status_Message == response_data["Status_Message"] + + +@pytest.mark.asyncio +async def test_async_cancel_single_invoice_success( + async_bill_manager, mock_async_http_client +): + """Test cancelling a single invoice asynchronously via Bill Manager.""" + request = valid_cancel_single_invoice_request() + response_data = { + "Status_Message": "Invoice cancelled successfully.", + "resmsg": "Success", + "rescode": "200", + "errors": [], + } + mock_async_http_client.post.return_value = response_data + response = await async_bill_manager.cancel_single_invoice(request) + assert isinstance(response, BillManagerCancelInvoiceResponse) + assert response.is_successful() is True + assert response.Status_Message == response_data["Status_Message"] + + +@pytest.mark.asyncio +async def test_async_cancel_bulk_invoice_success( + async_bill_manager, mock_async_http_client +): + """Test cancelling multiple invoices asynchronously via Bill Manager.""" + request = valid_cancel_bulk_invoice_request() + response_data = { + "Status_Message": "Invoices cancelled successfully.", + "resmsg": "Success", + "rescode": "200", + "errors": [], + } + mock_async_http_client.post.return_value = response_data + response = await async_bill_manager.cancel_bulk_invoice(request) + assert isinstance(response, BillManagerCancelInvoiceResponse) + assert response.Status_Message == response_data["Status_Message"] + + +@pytest.mark.asyncio +async def test_async_bill_manager_http_error( + async_bill_manager, mock_async_http_client +): + """Test handling of HTTP errors when sending a single invoice asynchronously.""" + request = valid_single_invoice_request() + mock_async_http_client.post.side_effect = Exception("HTTP error") + with pytest.raises(Exception) as excinfo: + await async_bill_manager.send_single_invoice(request) + assert "HTTP error" in str(excinfo.value) + + +@pytest.mark.asyncio +async def test_async_app_key_required_for_invoice( + mock_async_http_client, mock_async_token_manager +): + """Test app_key requirement for sending a single invoice asynchronously.""" + manager = AsyncBillManager( + http_client=mock_async_http_client, token_manager=mock_async_token_manager + ) + request = valid_single_invoice_request() + with pytest.raises(ValueError) as excinfo: + await manager.send_single_invoice(request) + assert "app_key must be set" in str(excinfo.value) diff --git a/tests/unit/bill_integration/test_bill_integration.py b/tests/unit/bill_manager/test_bill_manager.py similarity index 68% rename from tests/unit/bill_integration/test_bill_integration.py rename to tests/unit/bill_manager/test_bill_manager.py index e18b8e7..dd481c5 100644 --- a/tests/unit/bill_integration/test_bill_integration.py +++ b/tests/unit/bill_manager/test_bill_manager.py @@ -4,27 +4,28 @@ invoicing, cancellation, and error cases. """ -import pytest -from unittest.mock import MagicMock -from mpesakit.auth import TokenManager -from mpesakit.http_client import HttpClient -from mpesakit.bill_manager.bill_manager import BillManager from datetime import datetime +from unittest.mock import AsyncMock, MagicMock + +import pytest from pydantic import ValidationError +from mpesakit.auth import AsyncTokenManager, TokenManager +from mpesakit.bill_manager.bill_manager import AsyncBillManager, BillManager from mpesakit.bill_manager.schemas import ( - BillManagerOptInRequest, - BillManagerOptInResponse, - BillManagerUpdateOptInRequest, - BillManagerUpdateOptInResponse, - BillManagerSingleInvoiceRequest, - BillManagerSingleInvoiceResponse, BillManagerBulkInvoiceRequest, BillManagerBulkInvoiceResponse, - BillManagerCancelSingleInvoiceRequest, BillManagerCancelBulkInvoiceRequest, BillManagerCancelInvoiceResponse, + BillManagerCancelSingleInvoiceRequest, + BillManagerOptInRequest, + BillManagerOptInResponse, + BillManagerSingleInvoiceRequest, + BillManagerSingleInvoiceResponse, + BillManagerUpdateOptInRequest, + BillManagerUpdateOptInResponse, ) +from mpesakit.http_client import AsyncHttpClient, HttpClient @pytest.fixture @@ -343,6 +344,7 @@ def test_billed_period_invalid_raises(): BillManagerSingleInvoiceRequest(**req) assert "billedPeriod" in str(excinfo.value) + def test_result_code_as_string_does_not_raise(bill_manager, mock_http_client): """Ensure response.resultCode as a string does not cause type errors in is_successful.""" request = valid_single_invoice_request() @@ -362,3 +364,154 @@ def test_result_code_as_string_does_not_raise(bill_manager, mock_http_client): is_success = response.is_successful() assert isinstance(is_success, bool) + +@pytest.fixture +def mock_async_token_manager(): + """Mock AsyncTokenManager for testing purposes.""" + mock = AsyncMock(spec=AsyncTokenManager) + mock.get_token.return_value = "test_async_token" + return mock + + +@pytest.fixture +def mock_async_http_client(): + """Mock AsyncHttpClient for testing purposes.""" + return AsyncMock(spec=AsyncHttpClient) + + +@pytest.fixture +def async_bill_manager(mock_async_http_client, mock_async_token_manager): + """Fixture to create an AsyncBillManager instance with mocked AsyncHttpClient and AsyncTokenManager.""" + return AsyncBillManager( + http_client=mock_async_http_client, + token_manager=mock_async_token_manager, + app_key="test_app_key", + ) + + +@pytest.mark.asyncio +async def test_async_opt_in_success(async_bill_manager, mock_async_http_client): + """Test successful async opt-in to Bill Manager.""" + request = valid_opt_in_request() + response_data = { + "app_key": "AG_2376487236_126732989KJ", + "resmsg": "Success", + "rescode": "200", + } + mock_async_http_client.post.return_value = response_data + response = await async_bill_manager.opt_in(request) + assert isinstance(response, BillManagerOptInResponse) + assert response.app_key == response_data["app_key"] + assert response.rescode == "200" + + +@pytest.mark.asyncio +async def test_async_update_opt_in_success(async_bill_manager, mock_async_http_client): + """Test successful async update of opt-in settings for Bill Manager.""" + request = valid_update_opt_in_request() + response_data = { + "resmsg": "Success", + "rescode": "200", + } + mock_async_http_client.post.return_value = response_data + response = await async_bill_manager.update_opt_in(request) + assert isinstance(response, BillManagerUpdateOptInResponse) + assert response.rescode == "200" + + +@pytest.mark.asyncio +async def test_async_send_single_invoice_success( + async_bill_manager, mock_async_http_client +): + """Test sending a single invoice asynchronously via Bill Manager.""" + request = valid_single_invoice_request() + response_data = { + "Status_Message": "Invoice sent successfully", + "resmsg": "Success", + "rescode": "200", + } + mock_async_http_client.post.return_value = response_data + response = await async_bill_manager.send_single_invoice(request) + assert isinstance(response, BillManagerSingleInvoiceResponse) + assert response.is_successful() is True + assert response.Status_Message == response_data["Status_Message"] + + +@pytest.mark.asyncio +async def test_async_send_bulk_invoice_success( + async_bill_manager, mock_async_http_client +): + """Test sending multiple invoices asynchronously via Bill Manager.""" + request = valid_bulk_invoice_request() + response_data = { + "Status_Message": "Invoice sent successfully", + "resmsg": "Success", + "rescode": "200", + } + mock_async_http_client.post.return_value = response_data + response = await async_bill_manager.send_bulk_invoice(request) + assert isinstance(response, BillManagerBulkInvoiceResponse) + assert response.Status_Message == response_data["Status_Message"] + + +@pytest.mark.asyncio +async def test_async_cancel_single_invoice_success( + async_bill_manager, mock_async_http_client +): + """Test cancelling a single invoice asynchronously via Bill Manager.""" + request = valid_cancel_single_invoice_request() + response_data = { + "Status_Message": "Invoice cancelled successfully.", + "resmsg": "Success", + "rescode": "200", + "errors": [], + } + mock_async_http_client.post.return_value = response_data + response = await async_bill_manager.cancel_single_invoice(request) + assert isinstance(response, BillManagerCancelInvoiceResponse) + assert response.is_successful() is True + assert response.Status_Message == response_data["Status_Message"] + + +@pytest.mark.asyncio +async def test_async_cancel_bulk_invoice_success( + async_bill_manager, mock_async_http_client +): + """Test cancelling multiple invoices asynchronously via Bill Manager.""" + request = valid_cancel_bulk_invoice_request() + response_data = { + "Status_Message": "Invoices cancelled successfully.", + "resmsg": "Success", + "rescode": "200", + "errors": [], + } + mock_async_http_client.post.return_value = response_data + response = await async_bill_manager.cancel_bulk_invoice(request) + assert isinstance(response, BillManagerCancelInvoiceResponse) + assert response.Status_Message == response_data["Status_Message"] + + +@pytest.mark.asyncio +async def test_async_bill_manager_http_error( + async_bill_manager, mock_async_http_client +): + """Test handling of HTTP errors when sending a single invoice asynchronously.""" + request = valid_single_invoice_request() + mock_async_http_client.post.side_effect = Exception("HTTP error") + with pytest.raises(Exception) as excinfo: + await async_bill_manager.send_single_invoice(request) + assert "HTTP error" in str(excinfo.value) + + +@pytest.mark.asyncio +async def test_async_app_key_required_for_invoice( + mock_async_http_client, mock_async_token_manager +): + """Test app_key requirement for sending a single invoice asynchronously.""" + manager = AsyncBillManager( + http_client=mock_async_http_client, token_manager=mock_async_token_manager + ) + request = valid_single_invoice_request() + with pytest.raises(ValueError) as excinfo: + await manager.send_single_invoice(request) + assert "app_key must be set" in str(excinfo.value) From e1a15d3c633b4fd978da721f1280d1ecc1409695 Mon Sep 17 00:00:00 2001 From: rafaeljohn9 Date: Sun, 18 Jan 2026 15:14:52 +0300 Subject: [PATCH 06/23] fix (tests): removed bill integration unit tests since its a duplicate of bill manager unit tests --- .../bill_integration/test_bill_manager.py | 518 ------------------ 1 file changed, 518 deletions(-) delete mode 100644 tests/unit/bill_integration/test_bill_manager.py diff --git a/tests/unit/bill_integration/test_bill_manager.py b/tests/unit/bill_integration/test_bill_manager.py deleted file mode 100644 index c445aa2..0000000 --- a/tests/unit/bill_integration/test_bill_manager.py +++ /dev/null @@ -1,518 +0,0 @@ -"""Unit tests for the M-Pesa SDK Bill Manager functionality. - -This module tests the Bill Manager API client, ensuring it can handle onboarding, -invoicing, cancellation, and error cases. -""" - -from datetime import datetime -from unittest.mock import AsyncMock, MagicMock - -import pytest -from pydantic import ValidationError - -from mpesakit.auth import AsyncTokenManager, TokenManager -from mpesakit.bill_manager import ( - AsyncBillManager, - BillManager, - BillManagerBulkInvoiceRequest, - BillManagerBulkInvoiceResponse, - BillManagerCancelBulkInvoiceRequest, - BillManagerCancelInvoiceResponse, - BillManagerCancelSingleInvoiceRequest, - BillManagerOptInRequest, - BillManagerOptInResponse, - BillManagerSingleInvoiceRequest, - BillManagerSingleInvoiceResponse, - BillManagerUpdateOptInRequest, - BillManagerUpdateOptInResponse, -) -from mpesakit.http_client import AsyncHttpClient, HttpClient - - -@pytest.fixture -def mock_token_manager(): - """Mock TokenManager for testing purposes.""" - mock = MagicMock(spec=TokenManager) - mock.get_token.return_value = "test_token" - return mock - - -@pytest.fixture -def mock_http_client(): - """Mock HttpClient for testing purposes.""" - return MagicMock(spec=HttpClient) - - -@pytest.fixture -def bill_manager(mock_http_client, mock_token_manager): - """Fixture to create a BillManager instance with mocked HttpClient and TokenManager.""" - return BillManager( - http_client=mock_http_client, - token_manager=mock_token_manager, - app_key="test_app_key", - ) - - -def valid_opt_in_request(): - """Creates a valid opt-in request for Bill Manager.""" - return BillManagerOptInRequest( - shortcode=718003, - email="youremail@gmail.com", - officialContact="0710123456", - sendReminders=1, - logo="image", - callbackurl="http://my.server.com/bar/callback", - ) - - -def valid_update_opt_in_request(): - """Creates a valid update opt-in request for Bill Manager.""" - return BillManagerUpdateOptInRequest( - shortcode=718003, - email="youremail@gmail.com", - officialContact="0710123456", - sendReminders=1, - logo="image", - callbackurl="http://my.server.com/bar/callback", - ) - - -def valid_single_invoice_request(): - """Creates a valid single invoice request for Bill Manager.""" - return BillManagerSingleInvoiceRequest( - externalReference="#9932340", - billedFullName="John Doe", - billedPhoneNumber="0710123456", - billedPeriod="August 2021", - invoiceName="Jentrys", - dueDate="2021-10-12", - accountReference="1ASD678H", - amount=800, - invoiceItems=[ - {"itemName": "food", "amount": 700}, - {"itemName": "water", "amount": 100}, - ], - ) - - -def valid_bulk_invoice_request(): - """Creates a valid bulk invoice request for Bill Manager.""" - return BillManagerBulkInvoiceRequest(invoices=[valid_single_invoice_request()]) - - -def valid_cancel_single_invoice_request(): - """Creates a valid cancel single invoice request for Bill Manager.""" - return BillManagerCancelSingleInvoiceRequest(externalReference="113") - - -def valid_cancel_bulk_invoice_request(): - """Creates a valid cancel bulk invoice request for Bill Manager.""" - return BillManagerCancelBulkInvoiceRequest( - invoices=[ - BillManagerCancelSingleInvoiceRequest(externalReference="113"), - BillManagerCancelSingleInvoiceRequest(externalReference="114"), - ] - ) - - -def test_opt_in_success(bill_manager, mock_http_client): - """Test successful opt-in to Bill Manager.""" - request = valid_opt_in_request() - response_data = { - "app_key": "AG_2376487236_126732989KJ", - "resmsg": "Success", - "rescode": "200", - } - mock_http_client.post.return_value = response_data - response = bill_manager.opt_in(request) - assert isinstance(response, BillManagerOptInResponse) - assert response.app_key == response_data["app_key"] - assert response.rescode == "200" - - -def test_update_opt_in_success(bill_manager, mock_http_client): - """Test successful update of opt-in settings for Bill Manager.""" - request = valid_update_opt_in_request() - response_data = { - "resmsg": "Success", - "rescode": "200", - } - mock_http_client.post.return_value = response_data - response = bill_manager.update_opt_in(request) - assert isinstance(response, BillManagerUpdateOptInResponse) - assert response.rescode == "200" - - -def test_send_single_invoice_success(bill_manager, mock_http_client): - """Test sending a single invoice via Bill Manager.""" - request = valid_single_invoice_request() - response_data = { - "Status_Message": "Invoice sent successfully", - "resmsg": "Success", - "rescode": "200", - } - mock_http_client.post.return_value = response_data - response = bill_manager.send_single_invoice(request) - assert isinstance(response, BillManagerSingleInvoiceResponse) - assert response.is_successful() is True - assert response.Status_Message == response_data["Status_Message"] - - -def test_send_bulk_invoice_success(bill_manager, mock_http_client): - """Test sending multiple invoices via Bill Manager.""" - request = valid_bulk_invoice_request() - response_data = { - "Status_Message": "Invoice sent successfully", - "resmsg": "Success", - "rescode": "200", - } - mock_http_client.post.return_value = response_data - response = bill_manager.send_bulk_invoice(request) - assert isinstance(response, BillManagerBulkInvoiceResponse) - assert response.Status_Message == response_data["Status_Message"] - - -def test_cancel_single_invoice_success(bill_manager, mock_http_client): - """Test cancelling a single invoice via Bill Manager.""" - request = valid_cancel_single_invoice_request() - response_data = { - "Status_Message": "Invoice cancelled successfully.", - "resmsg": "Success", - "rescode": "200", - "errors": [], - } - mock_http_client.post.return_value = response_data - response = bill_manager.cancel_single_invoice(request) - assert isinstance(response, BillManagerCancelInvoiceResponse) - assert response.is_successful() is True - assert response.Status_Message == response_data["Status_Message"] - - -def test_cancel_bulk_invoice_success(bill_manager, mock_http_client): - """Test cancelling multiple invoices via Bill Manager.""" - request = valid_cancel_bulk_invoice_request() - response_data = { - "Status_Message": "Invoices cancelled successfully.", - "resmsg": "Success", - "rescode": "200", - "errors": [], - } - mock_http_client.post.return_value = response_data - response = bill_manager.cancel_bulk_invoice(request) - assert isinstance(response, BillManagerCancelInvoiceResponse) - assert response.Status_Message == response_data["Status_Message"] - - -def test_bill_manager_http_error(bill_manager, mock_http_client): - """Test handling of HTTP errors when sending a single invoice.""" - request = valid_single_invoice_request() - mock_http_client.post.side_effect = Exception("HTTP error") - with pytest.raises(Exception) as excinfo: - bill_manager.send_single_invoice(request) - assert "HTTP error" in str(excinfo.value) - - -def test_app_key_required_for_invoice(mock_http_client, mock_token_manager): - """Test app_key requirement for sending a single invoice.""" - manager = BillManager( - http_client=mock_http_client, token_manager=mock_token_manager - ) - request = valid_single_invoice_request() - with pytest.raises(ValueError) as excinfo: - manager.send_single_invoice(request) - assert "app_key must be set" in str(excinfo.value) - - -@pytest.mark.parametrize( - "due_date,expected", - [ - ("2021-10-12", "2021-10-12"), - ("2021/10/12", "2021-10-12"), - ("2021-10-12 14:30", "2021-10-12 14:30:00"), - ("2021-10-12 14:30:00", "2021-10-12 14:30:00.00"), - ("2021/10/12 14:30:00", "2021-10-12 14:30:00.00"), - ("2021-10-12 14:30:00.123", "2021-10-12 14:30:00.12"), - ("2021-10-12 14:30:00.10", "2021-10-12 14:30:00.10"), - ("2025-08-19T15:33:15.376886", "2025-08-19 15:33:15.37"), - ], -) -def test_due_date_valid_formats(due_date, expected): - """Test valid dueDate formats are accepted and normalized.""" - req = { - "externalReference": "#9932340", - "billedFullName": "John Doe", - "billedPhoneNumber": "0710123456", - "billedPeriod": "August 2021", - "invoiceName": "Jentrys", - "dueDate": due_date, - "accountReference": "1ASD678H", - "amount": 800, - "invoiceItems": [{"itemName": "food", "amount": 700}], - } - result = BillManagerSingleInvoiceRequest.model_validate(req) - - # Try parsing both result.dueDate and expected to datetime objects - def parse_due_date(date_str): - # Try different formats based on input - for fmt in [ - "%Y-%m-%d", - "%Y/%m/%d", - "%Y.%m.%d", - "%Y-%m-%d %H:%M:%S.%f", - "%Y/%m/%d %H:%M:%S.%f", - "%Y.%m.%d %H:%M:%S.%f", - "%Y-%m-%d %H:%M:%S", - "%Y/%m/%d %H:%M:%S", - "%Y.%m.%d %H:%M:%S", - "%Y-%m-%dT%H:%M:%S.%f", - ]: - try: - return datetime.strptime(date_str, fmt) - except Exception: - continue - raise ValueError(f"Unrecognized date format: {date_str}") - - dt_result = parse_due_date(result.dueDate) - dt_expected = parse_due_date(expected) - # Compare up to the second and first two digits of microseconds - assert dt_result.replace( - microsecond=(dt_result.microsecond // 10000) * 10000 - ) == dt_expected.replace(microsecond=(dt_expected.microsecond // 10000) * 10000) - - -@pytest.mark.parametrize( - "due_date", - [ - "2021-13-12", # invalid month - "2021-10-32", # invalid day - "20211012", # missing separators - "2021-10-12 14:30:00:00", # extra colon - "2021-10-12 14", # missing minutes and seconds - "12-10-2021", # wrong order - "", - ], -) -def test_due_date_invalid_formats_raise(due_date): - """Test invalid dueDate formats raise ValueError.""" - req = { - "externalReference": "#9932340", - "billedFullName": "John Doe", - "billedPhoneNumber": "0710123456", - "billedPeriod": "August 2021", - "invoiceName": "Jentrys", - "dueDate": due_date, - "accountReference": "1ASD678H", - "amount": 800, - "invoiceItems": [{"itemName": "food", "amount": 700}], - } - with pytest.raises(ValidationError) as excinfo: - BillManagerSingleInvoiceRequest(**req) - assert "validation error" in str(excinfo.value) - - -def test_due_date_missing_raises(): - """Test missing dueDate raises ValueError.""" - req = { - "externalReference": "#9932340", - "billedFullName": "John Doe", - "billedPhoneNumber": "0710123456", - "billedPeriod": "August 2021", - "invoiceName": "Jentrys", - # "dueDate" missing - "accountReference": "1ASD678H", - "amount": 800, - "invoiceItems": [{"itemName": "food", "amount": 700}], - } - with pytest.raises(ValidationError) as excinfo: - BillManagerSingleInvoiceRequest.model_validate(req) - assert "dueDate is required" in str(excinfo.value) - - -def test_billed_period_invalid_raises(): - """Test invalid billedPeriod raises ValueError.""" - req = { - "externalReference": "#9932340", - "billedFullName": "John Doe", - "billedPhoneNumber": "0710123456", - "billedPeriod": "2021-08", # invalid format - "invoiceName": "Jentrys", - "dueDate": "2021-10-12", - "accountReference": "1ASD678H", - "amount": 800, - "invoiceItems": [{"itemName": "food", "amount": 700}], - } - with pytest.raises(ValueError) as excinfo: - BillManagerSingleInvoiceRequest(**req) - assert "billedPeriod" in str(excinfo.value) - - -def test_result_code_as_string_does_not_raise(bill_manager, mock_http_client): - """Ensure response.resultCode as a string does not cause type errors in is_successful.""" - request = valid_single_invoice_request() - # resultCode intentionally a string to simulate APIs that return numeric codes as strings - response_data = { - "resultCode": "0", - "Status_Message": "Invoice sent successfully", - "resmsg": "Success", - "rescode": "200", - } - mock_http_client.post.return_value = response_data - - response = bill_manager.send_single_invoice(request) - - # Calling is_successful() should not raise a TypeError from comparing str and int; - # it should return a boolean result. - is_success = response.is_successful() - assert isinstance(is_success, bool) - - -@pytest.fixture -def mock_async_token_manager(): - """Mock AsyncTokenManager for testing purposes.""" - mock = AsyncMock(spec=AsyncTokenManager) - mock.get_token.return_value = "test_async_token" - return mock - - -@pytest.fixture -def mock_async_http_client(): - """Mock AsyncHttpClient for testing purposes.""" - return AsyncMock(spec=AsyncHttpClient) - - -@pytest.fixture -def async_bill_manager(mock_async_http_client, mock_async_token_manager): - """Fixture to create an AsyncBillManager instance with mocked AsyncHttpClient and AsyncTokenManager.""" - return AsyncBillManager( - http_client=mock_async_http_client, - token_manager=mock_async_token_manager, - app_key="test_app_key", - ) - - -@pytest.mark.asyncio -async def test_async_opt_in_success(async_bill_manager, mock_async_http_client): - """Test successful async opt-in to Bill Manager.""" - request = valid_opt_in_request() - response_data = { - "app_key": "AG_2376487236_126732989KJ", - "resmsg": "Success", - "rescode": "200", - } - mock_async_http_client.post.return_value = response_data - response = await async_bill_manager.opt_in(request) - assert isinstance(response, BillManagerOptInResponse) - assert response.app_key == response_data["app_key"] - assert response.rescode == "200" - - -@pytest.mark.asyncio -async def test_async_update_opt_in_success(async_bill_manager, mock_async_http_client): - """Test successful async update of opt-in settings for Bill Manager.""" - request = valid_update_opt_in_request() - response_data = { - "resmsg": "Success", - "rescode": "200", - } - mock_async_http_client.post.return_value = response_data - response = await async_bill_manager.update_opt_in(request) - assert isinstance(response, BillManagerUpdateOptInResponse) - assert response.rescode == "200" - - -@pytest.mark.asyncio -async def test_async_send_single_invoice_success( - async_bill_manager, mock_async_http_client -): - """Test sending a single invoice asynchronously via Bill Manager.""" - request = valid_single_invoice_request() - response_data = { - "Status_Message": "Invoice sent successfully", - "resmsg": "Success", - "rescode": "200", - } - mock_async_http_client.post.return_value = response_data - response = await async_bill_manager.send_single_invoice(request) - assert isinstance(response, BillManagerSingleInvoiceResponse) - assert response.is_successful() is True - assert response.Status_Message == response_data["Status_Message"] - - -@pytest.mark.asyncio -async def test_async_send_bulk_invoice_success( - async_bill_manager, mock_async_http_client -): - """Test sending multiple invoices asynchronously via Bill Manager.""" - request = valid_bulk_invoice_request() - response_data = { - "Status_Message": "Invoice sent successfully", - "resmsg": "Success", - "rescode": "200", - } - mock_async_http_client.post.return_value = response_data - response = await async_bill_manager.send_bulk_invoice(request) - assert isinstance(response, BillManagerBulkInvoiceResponse) - assert response.Status_Message == response_data["Status_Message"] - - -@pytest.mark.asyncio -async def test_async_cancel_single_invoice_success( - async_bill_manager, mock_async_http_client -): - """Test cancelling a single invoice asynchronously via Bill Manager.""" - request = valid_cancel_single_invoice_request() - response_data = { - "Status_Message": "Invoice cancelled successfully.", - "resmsg": "Success", - "rescode": "200", - "errors": [], - } - mock_async_http_client.post.return_value = response_data - response = await async_bill_manager.cancel_single_invoice(request) - assert isinstance(response, BillManagerCancelInvoiceResponse) - assert response.is_successful() is True - assert response.Status_Message == response_data["Status_Message"] - - -@pytest.mark.asyncio -async def test_async_cancel_bulk_invoice_success( - async_bill_manager, mock_async_http_client -): - """Test cancelling multiple invoices asynchronously via Bill Manager.""" - request = valid_cancel_bulk_invoice_request() - response_data = { - "Status_Message": "Invoices cancelled successfully.", - "resmsg": "Success", - "rescode": "200", - "errors": [], - } - mock_async_http_client.post.return_value = response_data - response = await async_bill_manager.cancel_bulk_invoice(request) - assert isinstance(response, BillManagerCancelInvoiceResponse) - assert response.Status_Message == response_data["Status_Message"] - - -@pytest.mark.asyncio -async def test_async_bill_manager_http_error( - async_bill_manager, mock_async_http_client -): - """Test handling of HTTP errors when sending a single invoice asynchronously.""" - request = valid_single_invoice_request() - mock_async_http_client.post.side_effect = Exception("HTTP error") - with pytest.raises(Exception) as excinfo: - await async_bill_manager.send_single_invoice(request) - assert "HTTP error" in str(excinfo.value) - - -@pytest.mark.asyncio -async def test_async_app_key_required_for_invoice( - mock_async_http_client, mock_async_token_manager -): - """Test app_key requirement for sending a single invoice asynchronously.""" - manager = AsyncBillManager( - http_client=mock_async_http_client, token_manager=mock_async_token_manager - ) - request = valid_single_invoice_request() - with pytest.raises(ValueError) as excinfo: - await manager.send_single_invoice(request) - assert "app_key must be set" in str(excinfo.value) From 822a827ab2b0e5fc20d01bc8f6c24e753538ab1d Mon Sep 17 00:00:00 2001 From: rafaeljohn9 Date: Sun, 18 Jan 2026 16:52:33 +0300 Subject: [PATCH 07/23] feat (Business PayBill): added async support for Business PayBill API with unit tests --- mpesakit/business_paybill/__init__.py | 3 +- mpesakit/business_paybill/business_paybill.py | 42 ++++++++- .../business_paybill/test_business_paybill.py | 92 ++++++++++++++++++- 3 files changed, 128 insertions(+), 9 deletions(-) diff --git a/mpesakit/business_paybill/__init__.py b/mpesakit/business_paybill/__init__.py index a35673e..844efdf 100644 --- a/mpesakit/business_paybill/__init__.py +++ b/mpesakit/business_paybill/__init__.py @@ -1,3 +1,4 @@ +from .business_paybill import AsyncBusinessPayBill, BusinessPayBill from .schemas import ( BusinessPayBillRequest, BusinessPayBillResponse, @@ -6,9 +7,9 @@ BusinessPayBillTimeoutCallback, BusinessPayBillTimeoutCallbackResponse, ) -from .business_paybill import BusinessPayBill __all__ = [ + "AsyncBusinessPayBill", "BusinessPayBill", "BusinessPayBillRequest", "BusinessPayBillResponse", diff --git a/mpesakit/business_paybill/business_paybill.py b/mpesakit/business_paybill/business_paybill.py index 9760a28..44beb33 100644 --- a/mpesakit/business_paybill/business_paybill.py +++ b/mpesakit/business_paybill/business_paybill.py @@ -5,8 +5,9 @@ """ from pydantic import BaseModel, ConfigDict -from mpesakit.auth import TokenManager -from mpesakit.http_client import HttpClient + +from mpesakit.auth import AsyncTokenManager, TokenManager +from mpesakit.http_client import AsyncHttpClient, HttpClient from .schemas import ( BusinessPayBillRequest, @@ -43,5 +44,40 @@ def paybill(self, request: BusinessPayBillRequest) -> BusinessPayBillResponse: "Authorization": f"Bearer {self.token_manager.get_token()}", "Content-Type": "application/json", } - response_data = self.http_client.post(url, json=request.model_dump(by_alias=True), headers=headers) + response_data = self.http_client.post( + url, json=request.model_dump(by_alias=True), headers=headers + ) + return BusinessPayBillResponse(**response_data) + + +class AsyncBusinessPayBill(BaseModel): + """Represents the async Business PayBill API client for M-Pesa operations. + + Attributes: + http_client (AsyncHttpClient): Async HTTP client for making requests to the M-Pesa API. + token_manager (AsyncTokenManager): Async token manager for authentication. + """ + + http_client: AsyncHttpClient + token_manager: AsyncTokenManager + + model_config = ConfigDict(arbitrary_types_allowed=True) + + async def paybill(self, request: BusinessPayBillRequest) -> BusinessPayBillResponse: + """Initiates a Business PayBill transaction asynchronously. + + Args: + request (BusinessPayBillRequest): The Business PayBill request data. + + Returns: + BusinessPayBillResponse: Response from the M-Pesa API. + """ + url = "/mpesa/b2b/v1/paymentrequest" + headers = { + "Authorization": f"Bearer {await self.token_manager.get_token()}", + "Content-Type": "application/json", + } + response_data = await self.http_client.post( + url, json=request.model_dump(by_alias=True), headers=headers + ) return BusinessPayBillResponse(**response_data) diff --git a/tests/unit/business_paybill/test_business_paybill.py b/tests/unit/business_paybill/test_business_paybill.py index 9e9d9bf..60a1f2f 100644 --- a/tests/unit/business_paybill/test_business_paybill.py +++ b/tests/unit/business_paybill/test_business_paybill.py @@ -4,13 +4,14 @@ process responses correctly, and manage error cases. """ +from unittest.mock import AsyncMock, MagicMock + import pytest -from unittest.mock import MagicMock -from mpesakit.auth import TokenManager -from mpesakit.http_client import HttpClient -from mpesakit.business_paybill.business_paybill import BusinessPayBill -from mpesakit.business_paybill.schemas import ( +from mpesakit.auth import AsyncTokenManager, TokenManager +from mpesakit.business_paybill import ( + AsyncBusinessPayBill, + BusinessPayBill, BusinessPayBillRequest, BusinessPayBillResponse, BusinessPayBillResultCallback, @@ -18,6 +19,7 @@ BusinessPayBillTimeoutCallback, BusinessPayBillTimeoutCallbackResponse, ) +from mpesakit.http_client import AsyncHttpClient, HttpClient @pytest.fixture @@ -148,6 +150,7 @@ def test_business_paybill_timeout_callback_response(): assert resp.ResultCode == 0 assert "Timeout notification received" in resp.ResultDesc + def test_business_paybill_result_callback_with_string_result_code(): """Ensure is_successful handles ResultCode provided as a string without raising TypeError.""" payload = { @@ -171,3 +174,82 @@ def test_business_paybill_result_callback_with_string_result_code(): callback = BusinessPayBillResultCallback(**payload) assert callback.is_successful() is True assert callback.Result.TransactionID == "QKA81LK5CY" + + +@pytest.fixture +def mock_async_token_manager(): + """Mock AsyncTokenManager to return a fixed token.""" + mock = AsyncMock(spec=AsyncTokenManager) + mock.get_token.return_value = "test_async_token" + return mock + + +@pytest.fixture +def mock_async_http_client(): + """Mock AsyncHttpClient to simulate async HTTP requests.""" + return AsyncMock(spec=AsyncHttpClient) + + +@pytest.fixture +def async_business_paybill(mock_async_http_client, mock_async_token_manager): + """Fixture to create an AsyncBusinessPayBill instance with mocked dependencies.""" + return AsyncBusinessPayBill( + http_client=mock_async_http_client, token_manager=mock_async_token_manager + ) + + +@pytest.mark.asyncio +async def test_async_paybill_request_acknowledged( + async_business_paybill, mock_async_http_client +): + """Test that async paybill request is acknowledged successfully.""" + request = valid_business_paybill_request() + response_data = { + "OriginatorConversationID": "5118-111210482-1", + "ConversationID": "AG_20230420_2010759fd5662ef6d054", + "ResponseCode": "0", + "ResponseDescription": "Accept the service request successfully.", + } + mock_async_http_client.post.return_value = response_data + + response = await async_business_paybill.paybill(request) + + assert isinstance(response, BusinessPayBillResponse) + assert response.is_successful() is True + assert response.ConversationID == response_data["ConversationID"] + assert ( + response.OriginatorConversationID == response_data["OriginatorConversationID"] + ) + + +@pytest.mark.asyncio +async def test_async_paybill_http_error(async_business_paybill, mock_async_http_client): + """Test handling of HTTP errors during async paybill request.""" + request = valid_business_paybill_request() + mock_async_http_client.post.side_effect = Exception("Async HTTP error") + with pytest.raises(Exception) as excinfo: + await async_business_paybill.paybill(request) + assert "Async HTTP error" in str(excinfo.value) + + +@pytest.mark.asyncio +async def test_async_paybill_token_retrieval( + async_business_paybill, mock_async_token_manager, mock_async_http_client +): + """Test that async paybill correctly retrieves and uses the token.""" + request = valid_business_paybill_request() + response_data = { + "OriginatorConversationID": "5118-111210482-1", + "ConversationID": "AG_20230420_2010759fd5662ef6d054", + "ResponseCode": "0", + "ResponseDescription": "Accept the service request successfully.", + } + mock_async_http_client.post.return_value = response_data + + await async_business_paybill.paybill(request) + + mock_async_token_manager.get_token.assert_awaited_once() + mock_async_http_client.post.assert_awaited_once() + call_args = mock_async_http_client.post.call_args + assert "Authorization" in call_args.kwargs["headers"] + assert "Bearer test_async_token" in call_args.kwargs["headers"]["Authorization"] From 9cf406a52b43c9b91ad04351924b0b769734304c Mon Sep 17 00:00:00 2001 From: rafaeljohn9 Date: Sun, 18 Jan 2026 16:53:16 +0300 Subject: [PATCH 08/23] feat (Business Buy Goods): added async support for Business Buy Goods API with unit tests --- mpesakit/business_buy_goods/__init__.py | 3 +- .../business_buy_goods/business_buy_goods.py | 44 ++++++- .../test_business_buy_goods.py | 107 +++++++++++++++++- 3 files changed, 147 insertions(+), 7 deletions(-) diff --git a/mpesakit/business_buy_goods/__init__.py b/mpesakit/business_buy_goods/__init__.py index e566cc1..d2f6ebc 100644 --- a/mpesakit/business_buy_goods/__init__.py +++ b/mpesakit/business_buy_goods/__init__.py @@ -1,3 +1,4 @@ +from .business_buy_goods import AsyncBusinessBuyGoods, BusinessBuyGoods from .schemas import ( BusinessBuyGoodsRequest, BusinessBuyGoodsResponse, @@ -6,9 +7,9 @@ BusinessBuyGoodsTimeoutCallback, BusinessBuyGoodsTimeoutCallbackResponse, ) -from .business_buy_goods import BusinessBuyGoods __all__ = [ + "AsyncBusinessBuyGoods", "BusinessBuyGoods", "BusinessBuyGoodsRequest", "BusinessBuyGoodsResponse", diff --git a/mpesakit/business_buy_goods/business_buy_goods.py b/mpesakit/business_buy_goods/business_buy_goods.py index 36b64e5..0e1eeb2 100644 --- a/mpesakit/business_buy_goods/business_buy_goods.py +++ b/mpesakit/business_buy_goods/business_buy_goods.py @@ -5,8 +5,9 @@ """ from pydantic import BaseModel, ConfigDict -from mpesakit.auth import TokenManager -from mpesakit.http_client import HttpClient + +from mpesakit.auth import AsyncTokenManager, TokenManager +from mpesakit.http_client import AsyncHttpClient, HttpClient from .schemas import ( BusinessBuyGoodsRequest, @@ -43,5 +44,42 @@ def buy_goods(self, request: BusinessBuyGoodsRequest) -> BusinessBuyGoodsRespons "Authorization": f"Bearer {self.token_manager.get_token()}", "Content-Type": "application/json", } - response_data = self.http_client.post(url, json=request.model_dump(by_alias=True), headers=headers) + response_data = self.http_client.post( + url, json=request.model_dump(by_alias=True), headers=headers + ) + return BusinessBuyGoodsResponse(**response_data) + + +class AsyncBusinessBuyGoods(BaseModel): + """Represents the async Business Buy Goods API client for M-Pesa operations. + + Attributes: + http_client (AsyncHttpClient): Async HTTP client for making requests to the M-Pesa API. + token_manager (AsyncTokenManager): Async token manager for authentication. + """ + + http_client: AsyncHttpClient + token_manager: AsyncTokenManager + + model_config = ConfigDict(arbitrary_types_allowed=True) + + async def buy_goods( + self, request: BusinessBuyGoodsRequest + ) -> BusinessBuyGoodsResponse: + """Initiates a Business Buy Goods transaction asynchronously. + + Args: + request (BusinessBuyGoodsRequest): The Business Buy Goods request data. + + Returns: + BusinessBuyGoodsResponse: Response from the M-Pesa API. + """ + url = "/mpesa/b2b/v1/paymentrequest" + headers = { + "Authorization": f"Bearer {await self.token_manager.get_token()}", + "Content-Type": "application/json", + } + response_data = await self.http_client.post( + url, json=request.model_dump(by_alias=True), headers=headers + ) return BusinessBuyGoodsResponse(**response_data) diff --git a/tests/unit/business_buy_goods/test_business_buy_goods.py b/tests/unit/business_buy_goods/test_business_buy_goods.py index 854f83e..1ea2e72 100644 --- a/tests/unit/business_buy_goods/test_business_buy_goods.py +++ b/tests/unit/business_buy_goods/test_business_buy_goods.py @@ -4,12 +4,13 @@ process responses correctly, and manage error cases. """ +from unittest.mock import AsyncMock, MagicMock + import pytest -from unittest.mock import MagicMock -from mpesakit.auth import TokenManager -from mpesakit.http_client import HttpClient +from mpesakit.auth import AsyncTokenManager, TokenManager from mpesakit.business_buy_goods import ( + AsyncBusinessBuyGoods, BusinessBuyGoods, BusinessBuyGoodsRequest, BusinessBuyGoodsResponse, @@ -18,6 +19,7 @@ BusinessBuyGoodsTimeoutCallback, BusinessBuyGoodsTimeoutCallbackResponse, ) +from mpesakit.http_client import AsyncHttpClient, HttpClient @pytest.fixture @@ -148,6 +150,7 @@ def test_business_buy_goods_timeout_callback_response(): assert resp.ResultCode == 0 assert "Timeout notification received" in resp.ResultDesc + def test_business_buy_goods_result_callback_resultcode_string(): """Ensure a string ResultCode does not raise when checking success.""" payload = { @@ -173,3 +176,101 @@ def test_business_buy_goods_result_callback_resultcode_string(): # Also assert it is considered successful. assert callback.is_successful() is True + +@pytest.fixture +def mock_async_token_manager(): + """Mock AsyncTokenManager to return a fixed token.""" + mock = AsyncMock(spec=AsyncTokenManager) + mock.get_token.return_value = "test_token" + return mock + + +@pytest.fixture +def mock_async_http_client(): + """Mock AsyncHttpClient to simulate async HTTP requests.""" + return AsyncMock(spec=AsyncHttpClient) + + +@pytest.fixture +def async_business_buy_goods(mock_async_http_client, mock_async_token_manager): + """Fixture to create an AsyncBusinessBuyGoods instance with mocked dependencies.""" + return AsyncBusinessBuyGoods( + http_client=mock_async_http_client, token_manager=mock_async_token_manager + ) + + +@pytest.mark.asyncio +async def test_async_buy_goods_request_acknowledged( + async_business_buy_goods, mock_async_http_client +): + """Test that async buy goods request is acknowledged.""" + request = valid_business_buy_goods_request() + response_data = { + "OriginatorConversationID": "5118-111210482-1", + "ConversationID": "AG_20230420_2010759fd5662ef6d054", + "ResponseCode": "0", + "ResponseDescription": "Accept the service request successfully.", + } + mock_async_http_client.post.return_value = response_data + + response = await async_business_buy_goods.buy_goods(request) + + assert isinstance(response, BusinessBuyGoodsResponse) + assert response.is_successful() is True + assert response.ConversationID == response_data["ConversationID"] + + +@pytest.mark.asyncio +async def test_async_buy_goods_http_error( + async_business_buy_goods, mock_async_http_client +): + """Test handling of HTTP errors during async buy goods request.""" + request = valid_business_buy_goods_request() + mock_async_http_client.post.side_effect = Exception("Async HTTP error") + + with pytest.raises(Exception) as excinfo: + await async_business_buy_goods.buy_goods(request) + + assert "Async HTTP error" in str(excinfo.value) + + +@pytest.mark.asyncio +async def test_async_buy_goods_token_manager_called( + async_business_buy_goods, mock_async_token_manager, mock_async_http_client +): + """Test that token manager is called during async buy goods request.""" + request = valid_business_buy_goods_request() + response_data = { + "OriginatorConversationID": "5118-111210482-1", + "ConversationID": "AG_20230420_2010759fd5662ef6d054", + "ResponseCode": "0", + "ResponseDescription": "Accept the service request successfully.", + } + mock_async_http_client.post.return_value = response_data + + await async_business_buy_goods.buy_goods(request) + + mock_async_token_manager.get_token.assert_called_once() + + +@pytest.mark.asyncio +async def test_async_buy_goods_http_client_called_with_correct_params( + async_business_buy_goods, mock_async_http_client +): + """Test that async HTTP client is called with correct parameters.""" + request = valid_business_buy_goods_request() + response_data = { + "OriginatorConversationID": "5118-111210482-1", + "ConversationID": "AG_20230420_2010759fd5662ef6d054", + "ResponseCode": "0", + "ResponseDescription": "Accept the service request successfully.", + } + mock_async_http_client.post.return_value = response_data + + await async_business_buy_goods.buy_goods(request) + + mock_async_http_client.post.assert_called_once() + call_args = mock_async_http_client.post.call_args + assert call_args[0][0] == "/mpesa/b2b/v1/paymentrequest" + assert "Authorization" in call_args[1]["headers"] + assert call_args[1]["headers"]["Content-Type"] == "application/json" From 986aa2a077af5241065849be6fec214a1057dd28 Mon Sep 17 00:00:00 2001 From: rafaeljohn9 Date: Sun, 18 Jan 2026 16:58:10 +0300 Subject: [PATCH 09/23] feat (C2B): added async support for C2B API with unit tests --- mpesakit/c2b/__init__.py | 9 ++-- mpesakit/c2b/c2b.py | 56 +++++++++++++++++-- tests/unit/c2b/test_c2b.py | 107 ++++++++++++++++++++++++++++++++++--- 3 files changed, 159 insertions(+), 13 deletions(-) diff --git a/mpesakit/c2b/__init__.py b/mpesakit/c2b/__init__.py index ba99f42..f39bddc 100644 --- a/mpesakit/c2b/__init__.py +++ b/mpesakit/c2b/__init__.py @@ -1,15 +1,17 @@ +from .c2b import C2B, AsyncC2B from .schemas import ( + C2BConfirmationResponse, C2BRegisterUrlRequest, C2BRegisterUrlResponse, + C2BResponseType, C2BValidationRequest, C2BValidationResponse, - C2BConfirmationResponse, C2BValidationResultCodeType, - C2BResponseType, ) -from .c2b import C2B __all__ = [ + "AsyncC2B", + "C2B", "C2BResponseType", "C2BRegisterUrlRequest", "C2BRegisterUrlResponse", @@ -17,5 +19,4 @@ "C2BValidationResponse", "C2BConfirmationResponse", "C2BValidationResultCodeType", - "C2B", ] diff --git a/mpesakit/c2b/c2b.py b/mpesakit/c2b/c2b.py index 5b5761f..ed99331 100644 --- a/mpesakit/c2b/c2b.py +++ b/mpesakit/c2b/c2b.py @@ -5,9 +5,9 @@ """ from pydantic import BaseModel, ConfigDict -from mpesakit.auth import TokenManager -from mpesakit.http_client import HttpClient +from mpesakit.auth import AsyncTokenManager, TokenManager +from mpesakit.http_client import AsyncHttpClient, HttpClient from .schemas import ( C2BRegisterUrlRequest, @@ -41,7 +41,57 @@ def register_url(self, request: C2BRegisterUrlRequest) -> C2BRegisterUrlResponse "Authorization": f"Bearer {self.token_manager.get_token()}", "Content-Type": "application/json", } - response_data = self.http_client.post(url, json=request.model_dump(by_alias=True), headers=headers) + response_data = self.http_client.post( + url, json=request.model_dump(by_alias=True), headers=headers + ) + + # Safaricom API Bug: There is a typo in the response field name + # "OriginatorCoversationID" should be "OriginatorConversationID" + if "OriginatorCoversationID" in response_data: + # Rename the field to match the expected schema + # This is a workaround for the API inconsistency + # and should be removed once the API is fixed. + response_data["OriginatorConversationID"] = response_data.pop( + "OriginatorCoversationID" + ) + + return C2BRegisterUrlResponse(**response_data) + + +class AsyncC2B(BaseModel): + """Represents the async C2B API client for M-Pesa Customer to Business operations. + + https://developer.safaricom.co.ke/APIs/CustomerToBusinessRegisterURL + + Attributes: + http_client (AsyncHttpClient): Async HTTP client for making requests to the M-Pesa API. + token_manager (AsyncTokenManager): Async token manager for authentication. + """ + + http_client: AsyncHttpClient + token_manager: AsyncTokenManager + + model_config = ConfigDict(arbitrary_types_allowed=True) + + async def register_url( + self, request: C2BRegisterUrlRequest + ) -> C2BRegisterUrlResponse: + """Registers validation and confirmation URLs for C2B payments asynchronously. + + Args: + request (C2BRegisterUrlRequest): The C2B URL registration request. + + Returns: + C2BRegisterUrlResponse: Response from the M-Pesa API after URL registration. + """ + url = "/mpesa/c2b/v1/registerurl" + headers = { + "Authorization": f"Bearer {await self.token_manager.get_token()}", + "Content-Type": "application/json", + } + response_data = await self.http_client.post( + url, json=request.model_dump(by_alias=True), headers=headers + ) # Safaricom API Bug: There is a typo in the response field name # "OriginatorCoversationID" should be "OriginatorConversationID" diff --git a/tests/unit/c2b/test_c2b.py b/tests/unit/c2b/test_c2b.py index f466820..37e8f83 100644 --- a/tests/unit/c2b/test_c2b.py +++ b/tests/unit/c2b/test_c2b.py @@ -1,20 +1,22 @@ """Unit tests for the C2B functionality of the Mpesa SDK.""" -import pytest -from unittest.mock import MagicMock import warnings +from unittest.mock import AsyncMock, MagicMock + +import pytest -from mpesakit.auth import TokenManager -from mpesakit.http_client import HttpClient +from mpesakit.auth import AsyncTokenManager, TokenManager from mpesakit.c2b import ( C2B, + AsyncC2B, + C2BConfirmationResponse, C2BRegisterUrlRequest, C2BRegisterUrlResponse, C2BValidationRequest, C2BValidationResponse, - C2BConfirmationResponse, C2BValidationResultCodeType, ) +from mpesakit.http_client import AsyncHttpClient, HttpClient @pytest.fixture @@ -367,6 +369,7 @@ def test_warn_long_resultdesc_none(monkeypatch): assert len(warn_calls) == 0 assert result == values + def test_is_successful_with_mixed_string_response_code_no_type_error(): """Ensure is_successful handles mixed/numeric-like string ResponseCode without TypeError and returns False for non-success codes.""" resp = C2BRegisterUrlResponse( @@ -379,6 +382,98 @@ def test_is_successful_with_mixed_string_response_code_no_type_error(): try: result = resp.is_successful() except TypeError as e: - pytest.fail(f"is_successful raised TypeError when ResponseCode is a mixed string: {e}") + pytest.fail( + f"is_successful raised TypeError when ResponseCode is a mixed string: {e}" + ) assert isinstance(result, bool) assert result is False + + +@pytest.fixture +def mock_async_token_manager(): + """Mock AsyncTokenManager to return a fixed token for testing.""" + mock = AsyncMock(spec=AsyncTokenManager) + mock.get_token.return_value = "test_async_token" + return mock + + +@pytest.fixture +def mock_async_http_client(): + """Mock AsyncHttpClient for testing.""" + return AsyncMock(spec=AsyncHttpClient) + + +@pytest.fixture +def async_c2b(mock_async_http_client, mock_async_token_manager): + """Fixture to create an instance of AsyncC2B with mocked dependencies.""" + return AsyncC2B( + http_client=mock_async_http_client, token_manager=mock_async_token_manager + ) + + +@pytest.mark.asyncio +async def test_async_register_url_success(async_c2b, mock_async_http_client): + """Test that a successful async C2B URL registration can be performed.""" + request = C2BRegisterUrlRequest( + ShortCode=600997, + ResponseType="Completed", + ConfirmationURL="https://domainpath.com/c2b/confirmation", + ValidationURL="https://domainpath.com/c2b/validation", + ) + response_data = { + "ResponseDescription": "Success", + "OriginatorCoversationID": "abc123", + "ConversationID": "conv456", + "CustomerMessage": "URLs registered", + "ResponseCode": "0", + } + mock_async_http_client.post.return_value = response_data + + response = await async_c2b.register_url(request) + + assert isinstance(response, C2BRegisterUrlResponse) + assert response.ResponseDescription == "Success" + assert response.OriginatorConversationID == "abc123" + mock_async_http_client.post.assert_called_once() + args, kwargs = mock_async_http_client.post.call_args + assert args[0] == "/mpesa/c2b/v1/registerurl" + assert kwargs["headers"]["Authorization"] == "Bearer test_async_token" + + +@pytest.mark.asyncio +async def test_async_register_url_handles_typo_field(async_c2b, mock_async_http_client): + """Test that the async C2B URL registration handles the typo in the response field.""" + request = C2BRegisterUrlRequest( + ShortCode=600997, + ResponseType="Completed", + ConfirmationURL="https://domainpath.com/c2b/confirmation", + ValidationURL="https://domainpath.com/c2b/validation", + ) + response_data = { + "ResponseDescription": "Success", + "OriginatorCoversationID": "typo123", + "ConversationID": "conv456", + "CustomerMessage": "URLs registered", + "ResponseCode": "0", + } + mock_async_http_client.post.return_value = response_data + + response = await async_c2b.register_url(request) + + assert response.OriginatorConversationID == "typo123" + + +@pytest.mark.asyncio +async def test_async_register_url_handles_http_error(async_c2b, mock_async_http_client): + """Test that the async C2B URL registration handles HTTP errors gracefully.""" + request = C2BRegisterUrlRequest( + ShortCode=600997, + ResponseType="Completed", + ConfirmationURL="https://domainpath.com/c2b/confirmation", + ValidationURL="https://domainpath.com/c2b/validation", + ) + mock_async_http_client.post.side_effect = Exception("HTTP error") + + with pytest.raises(Exception) as excinfo: + await async_c2b.register_url(request) + assert "HTTP error" in str(excinfo.value) From 95ff7bff92cbece5a8f0001cae25b741eda6a87d Mon Sep 17 00:00:00 2001 From: rafaeljohn9 Date: Sun, 18 Jan 2026 17:08:34 +0300 Subject: [PATCH 10/23] feat (Dynamic QR Code): added async support for Dynamic QR Code API with unit tests --- mpesakit/dynamic_qr_code/__init__.py | 3 +- mpesakit/dynamic_qr_code/dynamic_qr_code.py | 46 ++++++- .../dynamic_qr_code/test_dynamic_qr_code.py | 116 +++++++++++++++++- 3 files changed, 156 insertions(+), 9 deletions(-) diff --git a/mpesakit/dynamic_qr_code/__init__.py b/mpesakit/dynamic_qr_code/__init__.py index b5e3640..da0b380 100644 --- a/mpesakit/dynamic_qr_code/__init__.py +++ b/mpesakit/dynamic_qr_code/__init__.py @@ -1,4 +1,4 @@ -from .dynamic_qr_code import DynamicQRCode +from .dynamic_qr_code import AsyncDynamicQRCode, DynamicQRCode from .schemas import ( DynamicQRGenerateRequest, DynamicQRGenerateResponse, @@ -6,6 +6,7 @@ ) __all__ = [ + "AsyncDynamicQRCode", "DynamicQRCode", "DynamicQRGenerateRequest", "DynamicQRGenerateResponse", diff --git a/mpesakit/dynamic_qr_code/dynamic_qr_code.py b/mpesakit/dynamic_qr_code/dynamic_qr_code.py index 7d07bc3..5495062 100644 --- a/mpesakit/dynamic_qr_code/dynamic_qr_code.py +++ b/mpesakit/dynamic_qr_code/dynamic_qr_code.py @@ -5,8 +5,9 @@ """ from pydantic import BaseModel, ConfigDict -from mpesakit.auth import TokenManager -from mpesakit.http_client import HttpClient + +from mpesakit.auth import AsyncTokenManager, TokenManager +from mpesakit.http_client import AsyncHttpClient, HttpClient from .schemas import ( DynamicQRGenerateRequest, @@ -44,6 +45,45 @@ def generate(self, request: DynamicQRGenerateRequest) -> DynamicQRGenerateRespon "Content-Type": "application/json", } - response_data = self.http_client.post(url, json=request.model_dump(by_alias=True), headers=headers) + response_data = self.http_client.post( + url, json=request.model_dump(by_alias=True), headers=headers + ) + + return DynamicQRGenerateResponse(**response_data) + + +class AsyncDynamicQRCode(BaseModel): + """Represents the async Dynamic M-Pesa QR Code API client. + + Attributes: + http_client (AsyncHttpClient): Async HTTP client for making requests to the M-Pesa API. + token_manager (AsyncTokenManager): Async token manager for authentication. + """ + + http_client: AsyncHttpClient + token_manager: AsyncTokenManager + + model_config = ConfigDict(arbitrary_types_allowed=True) + + async def generate( + self, request: DynamicQRGenerateRequest + ) -> DynamicQRGenerateResponse: + """Generates a Dynamic M-Pesa QR Code asynchronously. + + Args: + request (DynamicQRGenerateRequest): The request data for generating the QR code. + + Returns: + DynamicQRGenerateResponse: The response from the M-Pesa API after generating the QR code. + """ + url = "/mpesa/qrcode/v1/generate" + headers = { + "Authorization": f"Bearer {await self.token_manager.get_token()}", + "Content-Type": "application/json", + } + + response_data = await self.http_client.post( + url, json=request.model_dump(by_alias=True), headers=headers + ) return DynamicQRGenerateResponse(**response_data) diff --git a/tests/unit/dynamic_qr_code/test_dynamic_qr_code.py b/tests/unit/dynamic_qr_code/test_dynamic_qr_code.py index 418886c..1f5ec88 100644 --- a/tests/unit/dynamic_qr_code/test_dynamic_qr_code.py +++ b/tests/unit/dynamic_qr_code/test_dynamic_qr_code.py @@ -1,14 +1,17 @@ """Unit tests for the Dynamic QR Code functionality of the Mpesa SDK.""" +from unittest.mock import AsyncMock, MagicMock + import pytest -from unittest.mock import MagicMock + +from mpesakit.auth import AsyncTokenManager, TokenManager from mpesakit.dynamic_qr_code import ( - DynamicQRGenerateRequest, + AsyncDynamicQRCode, DynamicQRCode, + DynamicQRGenerateRequest, DynamicQRTransactionType, ) -from mpesakit.auth import TokenManager -from mpesakit.http_client.mpesa_http_client import MpesaHttpClient +from mpesakit.http_client import AsyncHttpClient, MpesaHttpClient @pytest.fixture @@ -154,7 +157,10 @@ def test_generate_dynamic_qr_send_money_cpi_normalization(monkeypatch): excinfo.value ) -def test_generate_dynamic_qr_string_response_code_no_type_error(dynamic_qr_service, mock_http_client): + +def test_generate_dynamic_qr_string_response_code_no_type_error( + dynamic_qr_service, mock_http_client +): """Ensure ResponseCode as a string does not cause type comparison errors in is_successful().""" request = DynamicQRGenerateRequest( MerchantName="Test Supermarket", @@ -176,3 +182,103 @@ def test_generate_dynamic_qr_string_response_code_no_type_error(dynamic_qr_servi response = dynamic_qr_service.generate(request) assert response.is_successful() is True + +@pytest.fixture +def mock_async_token_manager(): + """Mock AsyncTokenManager to return a fixed token for testing.""" + mock = MagicMock(spec=AsyncTokenManager) + mock.get_token = AsyncMock(return_value="test_async_token") + return mock + + +@pytest.fixture +def mock_async_http_client(): + """Mock AsyncHttpClient for testing.""" + mock = MagicMock(spec=AsyncHttpClient) + mock.post = AsyncMock() + return mock + + +@pytest.fixture +def async_dynamic_qr_service(mock_async_http_client, mock_async_token_manager): + """Fixture to create an instance of AsyncDynamicQRCode with mocked dependencies.""" + return AsyncDynamicQRCode( + http_client=mock_async_http_client, token_manager=mock_async_token_manager + ) + + +@pytest.mark.asyncio +async def test_async_generate_dynamic_qr_success( + async_dynamic_qr_service, mock_async_http_client +): + """Test successful async Dynamic QR Code generation.""" + request = DynamicQRGenerateRequest( + MerchantName="Test Supermarket", + RefNo="xewr34fer4t", + Amount=200, + TrxCode=DynamicQRTransactionType.BUY_GOODS, + CPI="373132", + Size="300", + ) + response_data = { + "ResponseCode": "00", + "ResponseDescription": "Success", + "QRCode": "base64-encoded-string", + } + mock_async_http_client.post.return_value = response_data + + response = await async_dynamic_qr_service.generate(request) + + assert response.is_successful() is True + assert ( + getattr(response, "QRCode", None) == "base64-encoded-string" + or getattr(response, "qr_code", None) == "base64-encoded-string" + ) + mock_async_http_client.post.assert_called_once() + args, kwargs = mock_async_http_client.post.call_args + assert "Authorization" in kwargs["headers"] + assert kwargs["headers"]["Authorization"] == "Bearer test_async_token" + + +@pytest.mark.asyncio +async def test_async_generate_dynamic_qr_handles_http_error( + async_dynamic_qr_service, mock_async_http_client +): + """Test that an HTTP error during async Dynamic QR Code generation is handled.""" + request = DynamicQRGenerateRequest( + MerchantName="Test Supermarket", + RefNo="xewr34fer4t", + Amount=200, + TrxCode=DynamicQRTransactionType.BUY_GOODS, + CPI="373132", + Size="300", + ) + mock_async_http_client.post.side_effect = Exception("Async HTTP error") + + with pytest.raises(Exception) as excinfo: + await async_dynamic_qr_service.generate(request) + assert "Async HTTP error" in str(excinfo.value) + + +@pytest.mark.asyncio +async def test_async_generate_dynamic_qr_token_manager_called( + async_dynamic_qr_service, mock_async_token_manager, mock_async_http_client +): + """Test that the async token manager's get_token is properly awaited.""" + request = DynamicQRGenerateRequest( + MerchantName="Test Supermarket", + RefNo="xewr34fer4t", + Amount=200, + TrxCode=DynamicQRTransactionType.BUY_GOODS, + CPI="373132", + Size="300", + ) + mock_async_http_client.post.return_value = { + "ResponseCode": "00", + "ResponseDescription": "Success", + "QRCode": "base64-encoded-string", + } + + await async_dynamic_qr_service.generate(request) + + mock_async_token_manager.get_token.assert_called_once() From 8b5c47d422eadd0a6c06682999f419c1b37c7ba1 Mon Sep 17 00:00:00 2001 From: rafaeljohn9 Date: Sun, 18 Jan 2026 17:17:10 +0300 Subject: [PATCH 11/23] feat (Mpesa Express): added async support for Mpesa Express API with unit tests --- mpesakit/mpesa_express/__init__.py | 18 +-- mpesakit/mpesa_express/stk_push.py | 63 +++++++++- tests/unit/mpesa_express/test_stk_push.py | 140 +++++++++++++++++++++- 3 files changed, 202 insertions(+), 19 deletions(-) diff --git a/mpesakit/mpesa_express/__init__.py b/mpesakit/mpesa_express/__init__.py index 1f95b11..0f76498 100644 --- a/mpesakit/mpesa_express/__init__.py +++ b/mpesakit/mpesa_express/__init__.py @@ -1,19 +1,19 @@ from .schemas import ( - StkPushSimulateRequest, - StkPushSimulateResponse, - TransactionType, - StkPushSimulateCallbackMetadataItem, - StkPushSimulateCallbackMetadata, - StkPushSimulateCallback, - StkPushSimulateCallbackBody, StkCallback, StkPushQueryRequest, StkPushQueryResponse, + StkPushSimulateCallback, + StkPushSimulateCallbackBody, + StkPushSimulateCallbackMetadata, + StkPushSimulateCallbackMetadataItem, + StkPushSimulateRequest, + StkPushSimulateResponse, + TransactionType, ) -from .stk_push import StkPush - +from .stk_push import AsyncStkPush, StkPush __all__ = [ + "AsyncStkPush", "StkPush", "StkPushSimulateRequest", "StkPushSimulateResponse", diff --git a/mpesakit/mpesa_express/stk_push.py b/mpesakit/mpesa_express/stk_push.py index b780be5..5589a93 100644 --- a/mpesakit/mpesa_express/stk_push.py +++ b/mpesakit/mpesa_express/stk_push.py @@ -6,13 +6,14 @@ from pydantic import BaseModel, ConfigDict -from mpesakit.auth import TokenManager -from mpesakit.http_client import HttpClient +from mpesakit.auth import AsyncTokenManager, TokenManager +from mpesakit.http_client import AsyncHttpClient, HttpClient + from .schemas import ( - StkPushSimulateRequest, - StkPushSimulateResponse, StkPushQueryRequest, StkPushQueryResponse, + StkPushSimulateRequest, + StkPushSimulateResponse, ) @@ -43,7 +44,9 @@ def push(self, request: StkPushSimulateRequest) -> StkPushSimulateResponse: "Content-Type": "application/json", } - response_data = self.http_client.post(url, json=request.model_dump(by_alias=True), headers=headers) + response_data = self.http_client.post( + url, json=request.model_dump(by_alias=True), headers=headers + ) return StkPushSimulateResponse(**response_data) @@ -62,3 +65,53 @@ def query(self, request: StkPushQueryRequest) -> StkPushQueryResponse: response_data = self.http_client.post(url, json=dict(request), headers=headers) return StkPushQueryResponse(**response_data) + + +class AsyncStkPush(BaseModel): + """Represents the async STK Push API client for M-Pesa operations. + + Attributes: + http_client (AsyncHttpClient): Async HTTP client for making requests to the M-Pesa API. + token_manager (AsyncTokenManager): Async token manager for authentication. + """ + + http_client: AsyncHttpClient + token_manager: AsyncTokenManager + + model_config = ConfigDict(arbitrary_types_allowed=True) + + async def push(self, request: StkPushSimulateRequest) -> StkPushSimulateResponse: + """Initiates an M-Pesa STK Push transaction asynchronously. + + Returns: + StkPushSimulateResponse: The response from the M-Pesa API after initiating the STK Push. + """ + url = "/mpesa/stkpush/v1/processrequest" + headers = { + "Authorization": f"Bearer {await self.token_manager.get_token()}", + "Content-Type": "application/json", + } + + response_data = await self.http_client.post( + url, json=request.model_dump(by_alias=True), headers=headers + ) + + return StkPushSimulateResponse(**response_data) + + async def query(self, request: StkPushQueryRequest) -> StkPushQueryResponse: + """Queries the status of an M-Pesa STK Push transaction asynchronously. + + Returns: + StkPushQueryResponse: The response from the M-Pesa API after querying the transaction status. + """ + url = "/mpesa/stkpushquery/v1/query" + headers = { + "Authorization": f"Bearer {await self.token_manager.get_token()}", + "Content-Type": "application/json", + } + + response_data = await self.http_client.post( + url, json=dict(request), headers=headers + ) + + return StkPushQueryResponse(**response_data) diff --git a/tests/unit/mpesa_express/test_stk_push.py b/tests/unit/mpesa_express/test_stk_push.py index fd73a21..ed89055 100644 --- a/tests/unit/mpesa_express/test_stk_push.py +++ b/tests/unit/mpesa_express/test_stk_push.py @@ -3,17 +3,20 @@ This module tests the StkPush class for initiating and querying M-Pesa STK Push transactions. """ +from unittest.mock import AsyncMock, MagicMock + import pytest -from unittest.mock import MagicMock + +from mpesakit.auth import AsyncTokenManager, TokenManager +from mpesakit.http_client import AsyncHttpClient, HttpClient from mpesakit.mpesa_express.stk_push import ( + AsyncStkPush, StkPush, - StkPushSimulateRequest, - StkPushSimulateResponse, StkPushQueryRequest, StkPushQueryResponse, + StkPushSimulateRequest, + StkPushSimulateResponse, ) -from mpesakit.auth import TokenManager -from mpesakit.http_client import HttpClient @pytest.fixture @@ -156,3 +159,130 @@ def test_stk_push_simulate_request_invalid_transaction_type(): with pytest.raises(ValueError) as excinfo: StkPushSimulateRequest(**valid_kwargs) assert "TransactionType must be one of:" in str(excinfo.value) + + +@pytest.fixture +def mock_async_token_manager(): + """Mock AsyncTokenManager to return a fixed token for testing.""" + mock = AsyncMock(spec=AsyncTokenManager) + mock.get_token.return_value = "test_token" + return mock + + +@pytest.fixture +def mock_async_http_client(): + """Mock AsyncHttpClient for testing.""" + return AsyncMock(spec=AsyncHttpClient) + + +@pytest.fixture +def async_stk_push(mock_async_http_client, mock_async_token_manager): + """Fixture to create an instance of AsyncStkPush with mocked dependencies.""" + return AsyncStkPush( + http_client=mock_async_http_client, token_manager=mock_async_token_manager + ) + + +@pytest.mark.asyncio +async def test_async_push_success(async_stk_push, mock_async_http_client): + """Test that a successful async STK Push transaction can be initiated.""" + request = StkPushSimulateRequest( + BusinessShortCode=174379, + Password="test_password", + Timestamp="20220101010101", + TransactionType="CustomerPayBillOnline", + Amount=10, + PartyA="254700000000", + PartyB="174379", + PhoneNumber="254700000000", + CallBackURL="https://test.com/callback", + AccountReference="TestAccount", + TransactionDesc="Test Payment", + ) + response_data = { + "MerchantRequestID": "12345", + "CheckoutRequestID": "67890", + "ResponseCode": 0, + "ResponseDescription": "Success", + "CustomerMessage": "Success", + } + mock_async_http_client.post.return_value = response_data + + response = await async_stk_push.push(request) + + assert isinstance(response, StkPushSimulateResponse) + assert response.MerchantRequestID == "12345" + assert response.is_successful() is True + mock_async_http_client.post.assert_called_once() + args, kwargs = mock_async_http_client.post.call_args + assert args[0] == "/mpesa/stkpush/v1/processrequest" + assert kwargs["headers"]["Authorization"] == "Bearer test_token" + + +@pytest.mark.asyncio +async def test_async_query_success(async_stk_push, mock_async_http_client): + """Test that the status of an async STK Push transaction can be queried successfully.""" + request = StkPushQueryRequest( + BusinessShortCode=174379, + Password="test_password", + Timestamp="20220101010101", + CheckoutRequestID="ws_CO_260520211133524545", + ) + response_data = { + "MerchantRequestID": "12345", + "CheckoutRequestID": "ws_CO_260520211133524545", + "ResponseCode": 0, + "ResponseDescription": "Success", + "ResultCode": 0, + "ResultDesc": "The service request is processed successfully.", + } + mock_async_http_client.post.return_value = response_data + + response = await async_stk_push.query(request) + + assert isinstance(response, StkPushQueryResponse) + assert response.is_successful() is True + assert response.CheckoutRequestID == "ws_CO_260520211133524545" + mock_async_http_client.post.assert_called_once() + args, kwargs = mock_async_http_client.post.call_args + assert args[0] == "/mpesa/stkpushquery/v1/query" + assert kwargs["headers"]["Authorization"] == "Bearer test_token" + + +@pytest.mark.asyncio +async def test_async_push_handles_http_error(async_stk_push, mock_async_http_client): + """Test that an HTTP error during async STK Push initiation is handled.""" + request = StkPushSimulateRequest( + BusinessShortCode=174379, + Password="test_password", + Timestamp="20220101010101", + TransactionType="CustomerPayBillOnline", + Amount=10, + PartyA="254700000000", + PartyB="174379", + PhoneNumber="254700000000", + CallBackURL="https://test.com/callback", + AccountReference="TestAccount", + TransactionDesc="Test Payment", + ) + mock_async_http_client.post.side_effect = Exception("HTTP error") + + with pytest.raises(Exception) as excinfo: + await async_stk_push.push(request) + assert "HTTP error" in str(excinfo.value) + + +@pytest.mark.asyncio +async def test_async_query_handles_http_error(async_stk_push, mock_async_http_client): + """Test that an HTTP error during async STK Push query is handled.""" + request = StkPushQueryRequest( + BusinessShortCode=174379, + Password="test_password", + Timestamp="20220101010101", + CheckoutRequestID="67890", + ) + mock_async_http_client.post.side_effect = Exception("HTTP error") + + with pytest.raises(Exception) as excinfo: + await async_stk_push.query(request) + assert "HTTP error" in str(excinfo.value) From 262cc035baa4a2e50bc2e61994e880f82de071b4 Mon Sep 17 00:00:00 2001 From: rafaeljohn9 Date: Sun, 18 Jan 2026 17:21:30 +0300 Subject: [PATCH 12/23] feat (Mpesa Ratiba): added async support for Mpesa Ratiba API with unit tests --- mpesakit/mpesa_ratiba/__init__.py | 11 +-- mpesakit/mpesa_ratiba/mpesa_ratiba.py | 40 ++++++++- tests/unit/mpesa_ratiba/test_mpesa_ratiba.py | 88 ++++++++++++++++++-- 3 files changed, 124 insertions(+), 15 deletions(-) diff --git a/mpesakit/mpesa_ratiba/__init__.py b/mpesakit/mpesa_ratiba/__init__.py index ef02b7c..f163eba 100644 --- a/mpesakit/mpesa_ratiba/__init__.py +++ b/mpesakit/mpesa_ratiba/__init__.py @@ -1,15 +1,17 @@ +from .mpesa_ratiba import AsyncMpesaRatiba, MpesaRatiba from .schemas import ( FrequencyEnum, - TransactionTypeEnum, ReceiverPartyIdentifierTypeEnum, - StandingOrderRequest, - StandingOrderResponse, StandingOrderCallback, StandingOrderCallbackResponse, + StandingOrderRequest, + StandingOrderResponse, + TransactionTypeEnum, ) -from .mpesa_ratiba import MpesaRatiba __all__ = [ + "AsyncMpesaRatiba", + "MpesaRatiba", "StandingOrderRequest", "StandingOrderResponse", "StandingOrderCallback", @@ -17,5 +19,4 @@ "FrequencyEnum", "TransactionTypeEnum", "ReceiverPartyIdentifierTypeEnum", - "MpesaRatiba", ] diff --git a/mpesakit/mpesa_ratiba/mpesa_ratiba.py b/mpesakit/mpesa_ratiba/mpesa_ratiba.py index 89d4ef6..18b38b9 100644 --- a/mpesakit/mpesa_ratiba/mpesa_ratiba.py +++ b/mpesakit/mpesa_ratiba/mpesa_ratiba.py @@ -5,8 +5,9 @@ """ from pydantic import BaseModel, ConfigDict -from mpesakit.auth import TokenManager -from mpesakit.http_client import HttpClient + +from mpesakit.auth import AsyncTokenManager, TokenManager +from mpesakit.http_client import AsyncHttpClient, HttpClient from .schemas import ( StandingOrderRequest, @@ -49,3 +50,38 @@ def create_standing_order( url, json=request.model_dump(mode="json"), headers=headers ) return StandingOrderResponse(**response_data) + + +class AsyncMpesaRatiba(BaseModel): + """Represents the async Standing Order (Ratiba) API client for M-Pesa operations. + + Attributes: + http_client (AsyncHttpClient): Async HTTP client for making requests to the M-Pesa API. + token_manager (AsyncTokenManager): Async token manager for authentication. + """ + + http_client: AsyncHttpClient + token_manager: AsyncTokenManager + + model_config = ConfigDict(arbitrary_types_allowed=True) + + async def create_standing_order( + self, request: StandingOrderRequest + ) -> StandingOrderResponse: + """Initiates a Standing Order transaction asynchronously. + + Args: + request (StandingOrderRequest): The Standing Order request data. + + Returns: + StandingOrderResponse: Response from the M-Pesa API. + """ + url = "/standingorder/v1/createStandingOrderExternal" + headers = { + "Authorization": f"Bearer {await self.token_manager.get_token()}", + "Content-Type": "application/json", + } + response_data = await self.http_client.post( + url, json=request.model_dump(mode="json"), headers=headers + ) + return StandingOrderResponse(**response_data) diff --git a/tests/unit/mpesa_ratiba/test_mpesa_ratiba.py b/tests/unit/mpesa_ratiba/test_mpesa_ratiba.py index 72c0073..ad33a05 100644 --- a/tests/unit/mpesa_ratiba/test_mpesa_ratiba.py +++ b/tests/unit/mpesa_ratiba/test_mpesa_ratiba.py @@ -4,20 +4,22 @@ process responses correctly, and manage callback/error cases. """ +from unittest.mock import AsyncMock, MagicMock + import pytest -from unittest.mock import MagicMock -from mpesakit.auth import TokenManager -from mpesakit.http_client import HttpClient +from mpesakit.auth import AsyncTokenManager, TokenManager +from mpesakit.http_client import AsyncHttpClient, HttpClient from mpesakit.mpesa_ratiba import ( - MpesaRatiba, + AsyncMpesaRatiba, FrequencyEnum, - TransactionTypeEnum, + MpesaRatiba, ReceiverPartyIdentifierTypeEnum, - StandingOrderRequest, - StandingOrderResponse, StandingOrderCallback, StandingOrderCallbackResponse, + StandingOrderRequest, + StandingOrderResponse, + TransactionTypeEnum, ) @@ -245,6 +247,7 @@ def test_invalid_phone_number(): ) assert "Invalid PartyA phone number" in str(excinfo.value) + def test_callback_resultcode_as_string_handled_gracefully(): """Ensure StandingOrderCallback.is_successful() handles responseCode as a string without TypeError.""" payload = { @@ -267,6 +270,75 @@ def test_callback_resultcode_as_string_handled_gracefully(): try: result = callback.is_successful() except TypeError as exc: - pytest.fail(f"is_successful raised TypeError when responseCode is a string: {exc}") + pytest.fail( + f"is_successful raised TypeError when responseCode is a string: {exc}" + ) assert result is True + +@pytest.fixture +def mock_async_token_manager(): + """Mock AsyncTokenManager to return a fixed token.""" + mock = AsyncMock(spec=AsyncTokenManager) + mock.get_token.return_value = "test_token" + return mock + + +@pytest.fixture +def mock_async_http_client(): + """Mock AsyncHttpClient to simulate async HTTP requests.""" + return AsyncMock(spec=AsyncHttpClient) + + +@pytest.fixture +def async_mpesa_ratiba(mock_async_http_client, mock_async_token_manager): + """Fixture to create an AsyncMpesaRatiba instance with mocked dependencies.""" + return AsyncMpesaRatiba( + http_client=mock_async_http_client, token_manager=mock_async_token_manager + ) + + +@pytest.mark.asyncio +async def test_create_standing_order_success_async( + async_mpesa_ratiba, mock_async_http_client +): + """Test that async standing order request is acknowledged and successful.""" + request = valid_standing_order_request() + response_data = { + "ResponseHeader": { + "responseRefID": "4dd9b5d9-d738-42ba-9326-2cc99e966000", + "responseCode": "200", + "responseDescription": "Request accepted for processing", + "ResultDesc": "The service request is processed successfully.", + }, + "ResponseBody": { + "responseDescription": "Request accepted for processing", + "responseCode": "200", + }, + } + mock_async_http_client.post.return_value = response_data + + response = await async_mpesa_ratiba.create_standing_order(request) + + assert isinstance(response, StandingOrderResponse) + assert response.is_successful() is True + assert ( + response.ResponseHeader.responseCode + == response_data["ResponseHeader"]["responseCode"] + ) + assert ( + response.ResponseHeader.responseDescription + == response_data["ResponseHeader"]["responseDescription"] + ) + + +@pytest.mark.asyncio +async def test_create_standing_order_http_error_async( + async_mpesa_ratiba, mock_async_http_client +): + """Test handling of HTTP errors during async standing order request.""" + request = valid_standing_order_request() + mock_async_http_client.post.side_effect = Exception("HTTP error") + with pytest.raises(Exception) as excinfo: + await async_mpesa_ratiba.create_standing_order(request) + assert "HTTP error" in str(excinfo.value) From a04639803a89e01c67fa44df67da0c03af4dff32 Mon Sep 17 00:00:00 2001 From: rafaeljohn9 Date: Sun, 18 Jan 2026 17:29:15 +0300 Subject: [PATCH 13/23] feat (Reversal): added async support for Reversal API with unit tests. --- mpesakit/reversal/__init__.py | 3 +- mpesakit/reversal/reversal.py | 41 ++++++++- tests/unit/reversal/test_reversal.py | 123 ++++++++++++++++++++++++++- 3 files changed, 159 insertions(+), 8 deletions(-) diff --git a/mpesakit/reversal/__init__.py b/mpesakit/reversal/__init__.py index 153c7cf..a0d0ce3 100644 --- a/mpesakit/reversal/__init__.py +++ b/mpesakit/reversal/__init__.py @@ -1,3 +1,4 @@ +from .reversal import AsyncReversal, Reversal from .schemas import ( ReversalRequest, ReversalResponse, @@ -6,9 +7,9 @@ ReversalTimeoutCallback, ReversalTimeoutCallbackResponse, ) -from .reversal import Reversal __all__ = [ + "AsyncReversal", "Reversal", "ReversalRequest", "ReversalResponse", diff --git a/mpesakit/reversal/reversal.py b/mpesakit/reversal/reversal.py index 68c1d87..c4210a9 100644 --- a/mpesakit/reversal/reversal.py +++ b/mpesakit/reversal/reversal.py @@ -5,9 +5,9 @@ """ from pydantic import BaseModel, ConfigDict -from mpesakit.auth import TokenManager -from mpesakit.http_client import HttpClient +from mpesakit.auth import AsyncTokenManager, TokenManager +from mpesakit.http_client import AsyncHttpClient, HttpClient from .schemas import ( ReversalRequest, @@ -44,5 +44,40 @@ def reverse(self, request: ReversalRequest) -> ReversalResponse: "Authorization": f"Bearer {self.token_manager.get_token()}", "Content-Type": "application/json", } - response_data = self.http_client.post(url, json=request.model_dump(by_alias=True), headers=headers) + response_data = self.http_client.post( + url, json=request.model_dump(by_alias=True), headers=headers + ) + return ReversalResponse(**response_data) + + +class AsyncReversal(BaseModel): + """Represents the async Transaction Reversal API client for M-Pesa operations. + + Attributes: + http_client (AsyncHttpClient): Async HTTP client for making requests to the M-Pesa API. + token_manager (AsyncTokenManager): Async token manager for authentication. + """ + + http_client: AsyncHttpClient + token_manager: AsyncTokenManager + + model_config = ConfigDict(arbitrary_types_allowed=True) + + async def reverse(self, request: ReversalRequest) -> ReversalResponse: + """Initiates a transaction reversal asynchronously. + + Args: + request (ReversalRequest): The reversal request data. + + Returns: + ReversalResponse: Response from the M-Pesa API. + """ + url = "/mpesa/reversal/v1/request" + headers = { + "Authorization": f"Bearer {await self.token_manager.get_token()}", + "Content-Type": "application/json", + } + response_data = await self.http_client.post( + url, json=request.model_dump(by_alias=True), headers=headers + ) return ReversalResponse(**response_data) diff --git a/tests/unit/reversal/test_reversal.py b/tests/unit/reversal/test_reversal.py index 7ca642e..3f7479a 100644 --- a/tests/unit/reversal/test_reversal.py +++ b/tests/unit/reversal/test_reversal.py @@ -4,12 +4,12 @@ process responses correctly, and manage error cases. """ -from unittest.mock import MagicMock +from unittest.mock import AsyncMock, MagicMock import pytest -from mpesakit.auth import TokenManager -from mpesakit.http_client import HttpClient +from mpesakit.auth import AsyncTokenManager, TokenManager +from mpesakit.http_client import AsyncHttpClient, HttpClient from mpesakit.reversal import ( ReversalRequest, ReversalResponse, @@ -18,7 +18,7 @@ ReversalTimeoutCallback, ReversalTimeoutCallbackResponse, ) -from mpesakit.reversal.reversal import Reversal +from mpesakit.reversal.reversal import AsyncReversal, Reversal @pytest.fixture @@ -305,3 +305,118 @@ def test_reversal_result_callback_failure_code_is_successful(): } callback = ReversalResultCallback(**payload) assert callback.is_successful() is False + + +@pytest.fixture +def mock_async_token_manager(): + """Mock AsyncTokenManager to return a fixed token.""" + mock = AsyncMock(spec=AsyncTokenManager) + mock.get_token.return_value = "test_token_async" + return mock + + +@pytest.fixture +def mock_async_http_client(): + """Mock AsyncHttpClient to simulate async HTTP requests.""" + return AsyncMock(spec=AsyncHttpClient) + + +@pytest.fixture +def async_reversal(mock_async_http_client, mock_async_token_manager): + """Fixture to create an AsyncReversal instance with mocked dependencies.""" + return AsyncReversal( + http_client=mock_async_http_client, token_manager=mock_async_token_manager + ) + + +@pytest.mark.asyncio +async def test_async_reverse_request_acknowledged( + async_reversal, mock_async_http_client +): + """Test that async reversal request is acknowledged.""" + request = valid_reversal_request() + response_data = { + "OriginatorConversationID": "71840-27539181-07", + "ConversationID": "AG_20210709_12346c8e6f8858d7b70a", + "ResponseCode": "0", + "ResponseDescription": "Accept the service request successfully.", + } + mock_async_http_client.post.return_value = response_data + + response = await async_reversal.reverse(request) + + assert isinstance(response, ReversalResponse) + assert response.is_successful() is True + assert response.ConversationID == response_data["ConversationID"] + + +@pytest.mark.asyncio +async def test_async_reverse_http_error(async_reversal, mock_async_http_client): + """Test handling of HTTP errors during async reversal request.""" + request = valid_reversal_request() + mock_async_http_client.post.side_effect = Exception("Async HTTP error") + with pytest.raises(Exception) as excinfo: + await async_reversal.reverse(request) + assert "Async HTTP error" in str(excinfo.value) + + +@pytest.mark.asyncio +async def test_async_reverse_token_manager_called( + async_reversal, mock_async_token_manager, mock_async_http_client +): + """Test that async token manager's get_token is called.""" + request = valid_reversal_request() + response_data = { + "OriginatorConversationID": "71840-27539181-07", + "ConversationID": "AG_20210709_12346c8e6f8858d7b70a", + "ResponseCode": "0", + "ResponseDescription": "Accept the service request successfully.", + } + mock_async_http_client.post.return_value = response_data + + await async_reversal.reverse(request) + + mock_async_token_manager.get_token.assert_called_once() + + +@pytest.mark.asyncio +async def test_async_reverse_http_client_post_called( + async_reversal, mock_async_http_client +): + """Test that async HTTP client's post method is called with correct parameters.""" + request = valid_reversal_request() + response_data = { + "OriginatorConversationID": "71840-27539181-07", + "ConversationID": "AG_20210709_12346c8e6f8858d7b70a", + "ResponseCode": "0", + "ResponseDescription": "Accept the service request successfully.", + } + mock_async_http_client.post.return_value = response_data + + await async_reversal.reverse(request) + + assert mock_async_http_client.post.called + call_args = mock_async_http_client.post.call_args + assert call_args[0][0] == "/mpesa/reversal/v1/request" + assert "Authorization" in call_args[1]["headers"] + assert call_args[1]["headers"]["Content-Type"] == "application/json" + + +@pytest.mark.asyncio +async def test_async_reverse_responsecode_string_no_type_error( + async_reversal, mock_async_http_client +): + """Ensure async is_successful handles ResponseCode as a string without TypeError.""" + request = valid_reversal_request() + response_data = { + "OriginatorConversationID": "71840-27539181-07", + "ConversationID": "AG_20210709_12346c8e6f8858d7b70a", + "ResponseCode": "0", + "ResponseDescription": "Accept the service request successfully.", + } + mock_async_http_client.post.return_value = response_data + + response = await async_reversal.reverse(request) + + assert isinstance(response, ReversalResponse) + assert response.is_successful() is True From ae31069d09d3203b174b62c6f8f3e92cf1c0ba05 Mon Sep 17 00:00:00 2001 From: rafaeljohn9 Date: Sun, 18 Jan 2026 17:34:02 +0300 Subject: [PATCH 14/23] feat (C2B): added async support for C2B API with unit tests. --- mpesakit/tax_remittance/__init__.py | 3 +- mpesakit/tax_remittance/tax_remittance.py | 42 +++++++++- .../tax_remittance/test_tax_remittance.py | 84 ++++++++++++++++++- 3 files changed, 121 insertions(+), 8 deletions(-) diff --git a/mpesakit/tax_remittance/__init__.py b/mpesakit/tax_remittance/__init__.py index e60d3c4..ac3ea13 100644 --- a/mpesakit/tax_remittance/__init__.py +++ b/mpesakit/tax_remittance/__init__.py @@ -6,9 +6,10 @@ TaxRemittanceTimeoutCallback, TaxRemittanceTimeoutCallbackResponse, ) -from .tax_remittance import TaxRemittance +from .tax_remittance import AsyncTaxRemittance, TaxRemittance __all__ = [ + "AsyncTaxRemittance", "TaxRemittance", "TaxRemittanceRequest", "TaxRemittanceResponse", diff --git a/mpesakit/tax_remittance/tax_remittance.py b/mpesakit/tax_remittance/tax_remittance.py index e62f99c..3292e78 100644 --- a/mpesakit/tax_remittance/tax_remittance.py +++ b/mpesakit/tax_remittance/tax_remittance.py @@ -5,8 +5,9 @@ """ from pydantic import BaseModel, ConfigDict -from mpesakit.auth import TokenManager -from mpesakit.http_client import HttpClient + +from mpesakit.auth import AsyncTokenManager, TokenManager +from mpesakit.http_client import AsyncHttpClient, HttpClient from .schemas import ( TaxRemittanceRequest, @@ -43,5 +44,40 @@ def remittance(self, request: TaxRemittanceRequest) -> TaxRemittanceResponse: "Authorization": f"Bearer {self.token_manager.get_token()}", "Content-Type": "application/json", } - response_data = self.http_client.post(url, json=request.model_dump(by_alias=True), headers=headers) + response_data = self.http_client.post( + url, json=request.model_dump(by_alias=True), headers=headers + ) + return TaxRemittanceResponse(**response_data) + + +class AsyncTaxRemittance(BaseModel): + """Represents the async Tax Remittance API client for M-Pesa operations. + + Attributes: + http_client (AsyncHttpClient): Async HTTP client for making requests to the M-Pesa API. + token_manager (AsyncTokenManager): Async token manager for authentication. + """ + + http_client: AsyncHttpClient + token_manager: AsyncTokenManager + + model_config = ConfigDict(arbitrary_types_allowed=True) + + async def remittance(self, request: TaxRemittanceRequest) -> TaxRemittanceResponse: + """Initiates a tax remittance transaction asynchronously. + + Args: + request (TaxRemittanceRequest): The tax remittance request data. + + Returns: + TaxRemittanceResponse: Response from the M-Pesa API. + """ + url = "/mpesa/b2b/v1/remittax" + headers = { + "Authorization": f"Bearer {await self.token_manager.get_token()}", + "Content-Type": "application/json", + } + response_data = await self.http_client.post( + url, json=request.model_dump(by_alias=True), headers=headers + ) return TaxRemittanceResponse(**response_data) diff --git a/tests/unit/tax_remittance/test_tax_remittance.py b/tests/unit/tax_remittance/test_tax_remittance.py index bde3850..f5a7d50 100644 --- a/tests/unit/tax_remittance/test_tax_remittance.py +++ b/tests/unit/tax_remittance/test_tax_remittance.py @@ -4,12 +4,12 @@ process responses correctly, and manage error cases. """ +from unittest.mock import AsyncMock, MagicMock + import pytest -from unittest.mock import MagicMock -from mpesakit.auth import TokenManager -from mpesakit.http_client import HttpClient -from mpesakit.tax_remittance.tax_remittance import TaxRemittance +from mpesakit.auth import AsyncTokenManager, TokenManager +from mpesakit.http_client import AsyncHttpClient, HttpClient from mpesakit.tax_remittance import ( TaxRemittanceRequest, TaxRemittanceResponse, @@ -18,6 +18,7 @@ TaxRemittanceTimeoutCallback, TaxRemittanceTimeoutCallbackResponse, ) +from mpesakit.tax_remittance.tax_remittance import AsyncTaxRemittance, TaxRemittance @pytest.fixture @@ -145,6 +146,7 @@ def test_tax_remittance_timeout_callback_response(): assert resp.ResultCode == 0 assert "Timeout notification received" in resp.ResultDesc + def test_tax_remittance_result_callback_with_string_resultcode(): """Ensure is_successful handles ResultCode provided as a string without type errors.""" payload = { @@ -166,3 +168,77 @@ def test_tax_remittance_result_callback_with_string_resultcode(): callback = TaxRemittanceResultCallback(**payload) # Should not raise a TypeError comparing str and int; should treat "0" as success assert callback.is_successful() is True + + +@pytest.fixture +def mock_async_token_manager(): + """Mock AsyncTokenManager to return a fixed token.""" + mock = AsyncMock(spec=AsyncTokenManager) + mock.get_token.return_value = "test_token" + return mock + + +@pytest.fixture +def mock_async_http_client(): + """Mock AsyncHttpClient to simulate async HTTP requests.""" + return AsyncMock(spec=AsyncHttpClient) + + +@pytest.fixture +def async_tax_remittance(mock_async_http_client, mock_async_token_manager): + """Fixture to create an AsyncTaxRemittance instance with mocked dependencies.""" + return AsyncTaxRemittance( + http_client=mock_async_http_client, token_manager=mock_async_token_manager + ) + + +@pytest.mark.asyncio +async def test_async_remittance_request_acknowledged( + async_tax_remittance, mock_async_http_client +): + """Test that async remittance request is acknowledged.""" + request = valid_tax_remittance_request() + response_data = { + "OriginatorConversationID": "5118-111210482-1", + "ConversationID": "AG_20230420_2010759fd5662ef6d054", + "ResponseCode": "0", + "ResponseDescription": "Accept the service request successfully.", + } + mock_async_http_client.post.return_value = response_data + + response = await async_tax_remittance.remittance(request) + + assert isinstance(response, TaxRemittanceResponse) + assert response.is_successful() is True + assert response.ConversationID == response_data["ConversationID"] + + +@pytest.mark.asyncio +async def test_async_remittance_http_error( + async_tax_remittance, mock_async_http_client +): + """Test handling of HTTP errors during async remittance request.""" + request = valid_tax_remittance_request() + mock_async_http_client.post.side_effect = Exception("Async HTTP error") + with pytest.raises(Exception) as excinfo: + await async_tax_remittance.remittance(request) + assert "Async HTTP error" in str(excinfo.value) + + +@pytest.mark.asyncio +async def test_async_remittance_token_retrieval( + async_tax_remittance, mock_async_http_client, mock_async_token_manager +): + """Test that async remittance correctly retrieves token asynchronously.""" + request = valid_tax_remittance_request() + response_data = { + "OriginatorConversationID": "5118-111210482-1", + "ConversationID": "AG_20230420_2010759fd5662ef6d054", + "ResponseCode": "0", + "ResponseDescription": "Accept the service request successfully.", + } + mock_async_http_client.post.return_value = response_data + + await async_tax_remittance.remittance(request) + + mock_async_token_manager.get_token.assert_called_once() From 52bee6516715bccf7400fbe776a458b40298330a Mon Sep 17 00:00:00 2001 From: rafaeljohn9 Date: Sun, 18 Jan 2026 17:49:15 +0300 Subject: [PATCH 15/23] feat (Transaction Status): added async support for Transaction Status API with unit tests. --- mpesakit/transaction_status/__init__.py | 7 +- .../transaction_status/transaction_status.py | 44 +++++++++++- .../test_transaction_status.py | 69 ++++++++++++++++++- 3 files changed, 111 insertions(+), 9 deletions(-) diff --git a/mpesakit/transaction_status/__init__.py b/mpesakit/transaction_status/__init__.py index 3229605..96518fc 100644 --- a/mpesakit/transaction_status/__init__.py +++ b/mpesakit/transaction_status/__init__.py @@ -2,16 +2,17 @@ TransactionStatusIdentifierType, TransactionStatusRequest, TransactionStatusResponse, - TransactionStatusResultParameter, - TransactionStatusResultMetadata, TransactionStatusResultCallback, TransactionStatusResultCallbackResponse, + TransactionStatusResultMetadata, + TransactionStatusResultParameter, TransactionStatusTimeoutCallback, TransactionStatusTimeoutCallbackResponse, ) -from .transaction_status import TransactionStatus +from .transaction_status import AsyncTransactionStatus, TransactionStatus __all__ = [ + "AsyncTransactionStatus", "TransactionStatus", "TransactionStatusIdentifierType", "TransactionStatusRequest", diff --git a/mpesakit/transaction_status/transaction_status.py b/mpesakit/transaction_status/transaction_status.py index d43362f..8d97185 100644 --- a/mpesakit/transaction_status/transaction_status.py +++ b/mpesakit/transaction_status/transaction_status.py @@ -5,8 +5,9 @@ """ from pydantic import BaseModel, ConfigDict -from mpesakit.auth import TokenManager -from mpesakit.http_client import HttpClient + +from mpesakit.auth import AsyncTokenManager, TokenManager +from mpesakit.http_client import AsyncHttpClient, HttpClient from .schemas import ( TransactionStatusRequest, @@ -43,5 +44,42 @@ def query(self, request: TransactionStatusRequest) -> TransactionStatusResponse: "Authorization": f"Bearer {self.token_manager.get_token()}", "Content-Type": "application/json", } - response_data = self.http_client.post(url, json=request.model_dump(by_alias=True), headers=headers) + response_data = self.http_client.post( + url, json=request.model_dump(by_alias=True), headers=headers + ) + return TransactionStatusResponse(**response_data) + + +class AsyncTransactionStatus(BaseModel): + """Represents the async Transaction Status API client for M-Pesa operations. + + Attributes: + http_client (AsyncHttpClient): Async HTTP client for making requests to the M-Pesa API. + token_manager (AsyncTokenManager): Async token manager for authentication. + """ + + http_client: AsyncHttpClient + token_manager: AsyncTokenManager + + model_config = ConfigDict(arbitrary_types_allowed=True) + + async def query( + self, request: TransactionStatusRequest + ) -> TransactionStatusResponse: + """Queries the status of a transaction asynchronously. + + Args: + request (TransactionStatusRequest): The transaction status query request. + + Returns: + TransactionStatusResponse: Response from the M-Pesa API. + """ + url = "/mpesa/transactionstatus/v1/query" + headers = { + "Authorization": f"Bearer {await self.token_manager.get_token()}", + "Content-Type": "application/json", + } + response_data = await self.http_client.post( + url, json=request.model_dump(by_alias=True), headers=headers + ) return TransactionStatusResponse(**response_data) diff --git a/tests/unit/transaction_status/test_transaction_status.py b/tests/unit/transaction_status/test_transaction_status.py index 7cc5930..8fa5e63 100644 --- a/tests/unit/transaction_status/test_transaction_status.py +++ b/tests/unit/transaction_status/test_transaction_status.py @@ -3,13 +3,14 @@ This module tests the TransactionStatus class and its methods for querying transaction status. """ -from unittest.mock import MagicMock +from unittest.mock import AsyncMock, MagicMock import pytest -from mpesakit.auth import TokenManager -from mpesakit.http_client import HttpClient +from mpesakit.auth import AsyncTokenManager, TokenManager +from mpesakit.http_client import AsyncHttpClient, HttpClient from mpesakit.transaction_status import ( + AsyncTransactionStatus, TransactionStatus, TransactionStatusIdentifierType, TransactionStatusRequest, @@ -456,3 +457,65 @@ def test_transaction_status_result_callback_is_successful_empty_code(): ) callback = TransactionStatusResultCallback(Result=result) assert callback.is_successful() is False + + +@pytest.fixture +def mock_async_token_manager(): + """Mock AsyncTokenManager to return a fixed token.""" + mock = AsyncMock(spec=AsyncTokenManager) + mock.get_token.return_value = "test_token" + return mock + + +@pytest.fixture +def mock_async_http_client(): + """Mock AsyncHttpClient to simulate async HTTP requests.""" + return AsyncMock(spec=AsyncHttpClient) + + +@pytest.mark.asyncio +async def test_async_query_success(mock_async_http_client, mock_async_token_manager): + """Test querying transaction status asynchronously with a valid request.""" + request = valid_transaction_status_request() + response_data = { + "ConversationID": "AG_20170717_00006c6f7f5b8b6b1a62", + "OriginatorConversationID": "12345-67890-1", + "ResponseCode": "0", + "ResponseDescription": "Accept the service request successfully.", + } + mock_async_http_client.post.return_value = response_data + + async_transaction_status = AsyncTransactionStatus( + http_client=mock_async_http_client, + token_manager=mock_async_token_manager, + ) + + response = await async_transaction_status.query(request) + + assert isinstance(response, TransactionStatusResponse) + assert response.ConversationID == response_data["ConversationID"] + assert ( + response.OriginatorConversationID == response_data["OriginatorConversationID"] + ) + assert response.ResponseCode == response_data["ResponseCode"] + assert response.ResponseDescription == response_data["ResponseDescription"] + mock_async_http_client.post.assert_called_once() + args, kwargs = mock_async_http_client.post.call_args + assert args[0] == "/mpesa/transactionstatus/v1/query" + assert kwargs["headers"]["Authorization"] == "Bearer test_token" + + +@pytest.mark.asyncio +async def test_async_query_http_error(mock_async_http_client, mock_async_token_manager): + """Test handling HTTP errors during async transaction status query.""" + request = valid_transaction_status_request() + mock_async_http_client.post.side_effect = Exception("HTTP error") + async_transaction_status = AsyncTransactionStatus( + http_client=mock_async_http_client, + token_manager=mock_async_token_manager, + ) + + with pytest.raises(Exception) as excinfo: + await async_transaction_status.query(request) + + assert "HTTP error" in str(excinfo.value) From b043ff86fcc4dc0b77539b156f9c663d14dbc9ff Mon Sep 17 00:00:00 2001 From: rafaeljohn9 Date: Sun, 18 Jan 2026 18:10:06 +0300 Subject: [PATCH 16/23] fix (AsyncBillManager): Added 'Async' in 'BillManager' error message when app key is not passed --- mpesakit/bill_manager/bill_manager.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/mpesakit/bill_manager/bill_manager.py b/mpesakit/bill_manager/bill_manager.py index 804e876..212dfc6 100644 --- a/mpesakit/bill_manager/bill_manager.py +++ b/mpesakit/bill_manager/bill_manager.py @@ -161,7 +161,7 @@ async def opt_in( def _ensure_app_key(self): if self.app_key is None: raise ValueError( - "app_key must be set for this operation. You must pass it when initializing BillManager." + "app_key must be set for this operation. You must pass it when initializing AsyncBillManager." ) async def update_opt_in( From 9a56ae35ed967043c0b694ace2f2102de80b2d9f Mon Sep 17 00:00:00 2001 From: rafaeljohn9 Date: Sun, 18 Jan 2026 18:13:51 +0300 Subject: [PATCH 17/23] fix (B2B Express Checkout): Updated async tests to use AsyncMock instead of MagicMock --- .../unit/b2b_express_checkout/test_b2b_express_checkout.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/tests/unit/b2b_express_checkout/test_b2b_express_checkout.py b/tests/unit/b2b_express_checkout/test_b2b_express_checkout.py index 50c87a2..4375960 100644 --- a/tests/unit/b2b_express_checkout/test_b2b_express_checkout.py +++ b/tests/unit/b2b_express_checkout/test_b2b_express_checkout.py @@ -4,7 +4,7 @@ process responses correctly, and manage callback/error cases. """ -from unittest.mock import MagicMock +from unittest.mock import AsyncMock, MagicMock import pytest @@ -141,7 +141,7 @@ def test_b2b_express_callback_resultcode_as_string(): @pytest.fixture def mock_async_token_manager(): """Mock AsyncTokenManager to return a fixed token.""" - mock = MagicMock(spec=AsyncTokenManager) + mock = AsyncMock(spec=AsyncTokenManager) mock.get_token.return_value = "async_test_token" return mock @@ -149,7 +149,7 @@ def mock_async_token_manager(): @pytest.fixture def mock_async_http_client(): """Mock AsyncHttpClient to simulate async HTTP requests.""" - return MagicMock(spec=AsyncHttpClient) + return AsyncMock(spec=AsyncHttpClient) @pytest.fixture From e18390d32ab500eb17ffab1688bbd96508b44abf Mon Sep 17 00:00:00 2001 From: rafaeljohn9 Date: Sun, 18 Jan 2026 18:17:08 +0300 Subject: [PATCH 18/23] feat (Async Dynamic QR Code): Updated tests to assert await instead of called --- tests/unit/dynamic_qr_code/test_dynamic_qr_code.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/unit/dynamic_qr_code/test_dynamic_qr_code.py b/tests/unit/dynamic_qr_code/test_dynamic_qr_code.py index 1f5ec88..f20ecf8 100644 --- a/tests/unit/dynamic_qr_code/test_dynamic_qr_code.py +++ b/tests/unit/dynamic_qr_code/test_dynamic_qr_code.py @@ -281,4 +281,4 @@ async def test_async_generate_dynamic_qr_token_manager_called( await async_dynamic_qr_service.generate(request) - mock_async_token_manager.get_token.assert_called_once() + mock_async_token_manager.get_token.assert_awaited_once() From 6a713a8a85e8db9e42ab6e36676bd786cf81c3ad Mon Sep 17 00:00:00 2001 From: rafaeljohn9 Date: Sun, 18 Jan 2026 18:20:02 +0300 Subject: [PATCH 19/23] fix (Account Balance): Updated async tests to use AsyncMock instead of MagicMock --- tests/unit/account_balance/test_account_balance.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/tests/unit/account_balance/test_account_balance.py b/tests/unit/account_balance/test_account_balance.py index fac33af..4e05123 100644 --- a/tests/unit/account_balance/test_account_balance.py +++ b/tests/unit/account_balance/test_account_balance.py @@ -3,7 +3,7 @@ This module tests the AccountBalance class and its methods for querying account balance. """ -from unittest.mock import MagicMock +from unittest.mock import AsyncMock, MagicMock import pytest @@ -226,7 +226,7 @@ def test_query_handles_string_response_code(account_balance, mock_http_client): @pytest.fixture def mock_async_token_manager(): """Mock AsyncTokenManager for testing.""" - mock = MagicMock(spec=AsyncTokenManager) + mock = AsyncMock(spec=AsyncTokenManager) mock.get_token.return_value = "test_async_token" return mock @@ -234,7 +234,7 @@ def mock_async_token_manager(): @pytest.fixture def mock_async_http_client(): """Mock AsyncHttpClient for testing.""" - return MagicMock(spec=AsyncHttpClient) + return AsyncMock(spec=AsyncHttpClient) @pytest.fixture From 782314f60ad8b7e04c3f93c1c4c9d87f14cdd4c1 Mon Sep 17 00:00:00 2001 From: rafaeljohn9 Date: Sun, 18 Jan 2026 18:22:55 +0300 Subject: [PATCH 20/23] feat (Reversal): Updated tests to assert await instead of called and added stricter assertion to response header --- tests/unit/reversal/test_reversal.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/tests/unit/reversal/test_reversal.py b/tests/unit/reversal/test_reversal.py index 3f7479a..0747ec3 100644 --- a/tests/unit/reversal/test_reversal.py +++ b/tests/unit/reversal/test_reversal.py @@ -376,7 +376,7 @@ async def test_async_reverse_token_manager_called( await async_reversal.reverse(request) - mock_async_token_manager.get_token.assert_called_once() + mock_async_token_manager.get_token.assert_awaited_once() @pytest.mark.asyncio @@ -399,6 +399,7 @@ async def test_async_reverse_http_client_post_called( call_args = mock_async_http_client.post.call_args assert call_args[0][0] == "/mpesa/reversal/v1/request" assert "Authorization" in call_args[1]["headers"] + assert call_args[1]["headers"]["Authorization"] == "Bearer test_token_async" assert call_args[1]["headers"]["Content-Type"] == "application/json" From da67b14f82829d579315e1e4b8a94064ae406c06 Mon Sep 17 00:00:00 2001 From: rafaeljohn9 Date: Sun, 18 Jan 2026 18:24:14 +0300 Subject: [PATCH 21/23] fix (B2C): Updated tests misleading name. --- tests/unit/b2c/test_b2c.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/unit/b2c/test_b2c.py b/tests/unit/b2c/test_b2c.py index 842a54e..79ad532 100644 --- a/tests/unit/b2c/test_b2c.py +++ b/tests/unit/b2c/test_b2c.py @@ -475,10 +475,10 @@ async def test_async_send_payment_success( @pytest.mark.asyncio -async def test_async_send_payment_http_error( +async def test_async_send_payment_token_error( async_b2c, mock_async_http_client, mock_async_token_manager ): - """Test that async B2C payment handles HTTP errors gracefully.""" + """Test that async B2C payment handles token errors gracefully.""" request = valid_b2c_request() mock_async_token_manager.get_token.side_effect = Exception("Token error") From e11a25a54775d22836f50daa3cce29c4365c1cf0 Mon Sep 17 00:00:00 2001 From: rafaeljohn9 Date: Sun, 18 Jan 2026 18:27:50 +0300 Subject: [PATCH 22/23] feat (Mpesa Express): Updated tests to assert await instead of called and added stricter assertions --- tests/unit/mpesa_express/test_stk_push.py | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/tests/unit/mpesa_express/test_stk_push.py b/tests/unit/mpesa_express/test_stk_push.py index ed89055..26248f8 100644 --- a/tests/unit/mpesa_express/test_stk_push.py +++ b/tests/unit/mpesa_express/test_stk_push.py @@ -184,7 +184,9 @@ def async_stk_push(mock_async_http_client, mock_async_token_manager): @pytest.mark.asyncio -async def test_async_push_success(async_stk_push, mock_async_http_client): +async def test_async_push_success( + async_stk_push, mock_async_http_client, mock_async_token_manager +): """Test that a successful async STK Push transaction can be initiated.""" request = StkPushSimulateRequest( BusinessShortCode=174379, @@ -213,14 +215,16 @@ async def test_async_push_success(async_stk_push, mock_async_http_client): assert isinstance(response, StkPushSimulateResponse) assert response.MerchantRequestID == "12345" assert response.is_successful() is True - mock_async_http_client.post.assert_called_once() + mock_async_http_client.post.assert_awaited_once() args, kwargs = mock_async_http_client.post.call_args assert args[0] == "/mpesa/stkpush/v1/processrequest" assert kwargs["headers"]["Authorization"] == "Bearer test_token" @pytest.mark.asyncio -async def test_async_query_success(async_stk_push, mock_async_http_client): +async def test_async_query_success( + async_stk_push, mock_async_http_client, mock_async_token_manager +): """Test that the status of an async STK Push transaction can be queried successfully.""" request = StkPushQueryRequest( BusinessShortCode=174379, @@ -243,7 +247,7 @@ async def test_async_query_success(async_stk_push, mock_async_http_client): assert isinstance(response, StkPushQueryResponse) assert response.is_successful() is True assert response.CheckoutRequestID == "ws_CO_260520211133524545" - mock_async_http_client.post.assert_called_once() + mock_async_http_client.post.assert_awaited_once() args, kwargs = mock_async_http_client.post.call_args assert args[0] == "/mpesa/stkpushquery/v1/query" assert kwargs["headers"]["Authorization"] == "Bearer test_token" From 4ea020f49f0c5d8279b5058c0ec4afca116d960e Mon Sep 17 00:00:00 2001 From: rafaeljohn9 Date: Sun, 18 Jan 2026 18:59:34 +0300 Subject: [PATCH 23/23] tests (Account Balance): updated assertion to assert await instead of call with appropriate args. --- tests/unit/account_balance/test_account_balance.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/unit/account_balance/test_account_balance.py b/tests/unit/account_balance/test_account_balance.py index 4e05123..b407b53 100644 --- a/tests/unit/account_balance/test_account_balance.py +++ b/tests/unit/account_balance/test_account_balance.py @@ -270,8 +270,8 @@ async def test_async_query_returns_acknowledgement( assert response.ResponseCode == response_data["ResponseCode"] assert response.ResponseDescription == response_data["ResponseDescription"] - mock_async_http_client.post.assert_called_once() - args, kwargs = mock_async_http_client.post.call_args + mock_async_http_client.post.assert_awaited_once() + args, kwargs = mock_async_http_client.post.await_args assert args[0] == "/mpesa/accountbalance/v1/query" assert kwargs["headers"]["Authorization"] == "Bearer test_async_token" assert kwargs["headers"]["Content-Type"] == "application/json"