Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 9 additions & 11 deletions docs/src/components/MpesaKit.module.css
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,8 @@
--mpesa-green: #00D13A;
--mpesa-dark-green: #00B032;
--mpesa-light-green: #4AE668;
--dark-bg: #0a0a0a;
--card-bg: rgba(255, 255, 255, 0.05);
--text-primary: #ffffff;
--card-bg: rgba(0, 0, 0, 0.05);
--text-primary: #1a1a1a;
--text-secondary: #a0a0a0;
--gradient-primary: linear-gradient(135deg, var(--mpesa-green) 0%, var(--mpesa-light-green) 100%);
--gradient-dark: linear-gradient(135deg, #1a1a1a 0%, #2a2a2a 100%);
Expand Down Expand Up @@ -140,10 +139,10 @@
justify-content: center;
flex-direction: column;
}

.btn {
padding: 0.875rem 1.5rem;
border: none;
border: 1px solid rgba(0, 0, 0, 0.1);
border-radius: 50px;
border-radius: 50px;
font-weight: 600;
text-decoration: none;
Expand Down Expand Up @@ -259,6 +258,7 @@
overflow-x: auto;
-webkit-overflow-scrolling: touch;
white-space: nowrap;
color: #f8f8f8;
}

.codeLine {
Expand Down Expand Up @@ -610,11 +610,11 @@
grid-template-columns: repeat(auto-fit, minmax(350px, 1fr));
gap: 2rem;
}

.featureCard {
background: var(--card-bg);
border-radius: 20px;
padding: 2.5rem;
border: 1px solid rgba(0, 0, 0, 0.1);
border: 1px solid rgba(255, 255, 255, 0.1);
Comment on lines +617 to 618
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Conflicting border definitions on .featureCard.

Lines 617 and 618 both set border, but with different colors: rgba(0, 0, 0, 0.1) (dark) and rgba(255, 255, 255, 0.1) (light). The second declaration will override the first, making the first line ineffective.

Clarify the intended border style and apply a single border definition:

  .featureCard {
    background: var(--card-bg);
    border-radius: 20px;
    padding: 2.5rem;
-   border: 1px solid rgba(0, 0, 0, 0.1);
    border: 1px solid rgba(255, 255, 255, 0.1);
    transition: all 0.3s ease;

Alternatively, if the intent is to use a dark border for light mode, remove line 618 and keep line 617.

📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
border: 1px solid rgba(0, 0, 0, 0.1);
border: 1px solid rgba(255, 255, 255, 0.1);
border: 1px solid rgba(255, 255, 255, 0.1);
🧰 Tools
🪛 Biome (2.1.2)

[error] 618-618: Duplicate properties can lead to unexpected behavior and may override previous declarations unintentionally.

border is already defined here.

Remove or rename the duplicate property to ensure consistent styling.

(lint/suspicious/noDuplicateProperties)

🤖 Prompt for AI Agents
In docs/src/components/MpesaKit.module.css around lines 617-618 there are two
conflicting border declarations for .featureCard (rgba(0,0,0,0.1) and
rgba(255,255,255,0.1)) so remove the unintended one and keep the intended color;
if you need different borders for light/dark themes instead of duplicate lines,
replace them with a single base border and add a prefers-color-scheme media
query (or use a CSS variable) to override the color for dark mode.

transition: all 0.3s ease;
position: relative;
Expand Down Expand Up @@ -738,15 +738,13 @@
font-size: 1.2rem;
flex-shrink: 0;
}

.securityVisual {
/* Styles handled inline in component */
}
/* Security visual styles are handled inline in the component */

/* API Status Section */
.apiStatus {
padding: 6rem 5%;
background: rgba(0, 0, 0, 0.3);
background: rgba(0, 209, 58, 0.02);
color: var(--text-primary);
}

.statusGrid {
Expand Down
38 changes: 11 additions & 27 deletions mpesakit/http_client/mpesa_http_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
"""

from typing import Dict, Any, Optional
import requests
import httpx
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical

🧩 Analysis chain

Verify httpx is declared as a dependency.

The code now imports and uses httpx, but there's no indication that this dependency has been added to the project's dependency manifest (e.g., requirements.txt, setup.py, pyproject.toml). This will cause import errors in production.

Run the following script to check if httpx is declared as a dependency:


🏁 Script executed:

#!/bin/bash
# Check if httpx is declared in common dependency files

echo "=== Checking for httpx in dependency files ==="

fd -t f 'requirements.*\.txt$|setup\.py$|pyproject\.toml$|Pipfile$' --exec echo "File: {}" \; --exec grep -n "httpx" {} \; 2>/dev/null || echo "No httpx dependency found"

Length of output: 95


I need to inspect the actual contents of pyproject.toml to verify the current dependencies.


🏁 Script executed:

#!/bin/bash
# View the contents of pyproject.toml to see current dependencies
cat ./pyproject.toml

Length of output: 2651


Add httpx to project dependencies in pyproject.toml.

The code imports httpx, but it is not declared in the dependencies list. This will cause import errors when the package is installed. Add httpx to the dependencies section in pyproject.toml:

dependencies = [ 
  "pydantic >=2.10.6,<3.0.0",
  "pydantic[email] >=2.10.6,<3.0.0",
  "requests >=2.32.3,<3.0.0",
  "typing_extensions >= 4.12.2,<5.0.0",
  "cryptography >=41.0.7",
  "httpx",  # Add version constraint as appropriate
]
🤖 Prompt for AI Agents
In mpesakit/http_client/mpesa_http_client.py at line 7 the module imports httpx
but the package doesn't declare httpx in pyproject.toml; open pyproject.toml,
locate the [project] / dependencies list and add an appropriate
version-constrained entry for "httpx" (for example "httpx >=0.24.0,<1.0.0" or
whatever your compatibility policy requires), save and run
poetry/pipenv/installer to update lockfiles so the dependency is installed
during packaging and CI.


from mpesakit.errors import MpesaError, MpesaApiException
from .http_client import HttpClient
Expand Down Expand Up @@ -54,14 +54,15 @@ def post(
"""
try:
full_url = f"{self.base_url}{url}"
response = requests.post(full_url, json=json, headers=headers, timeout=10)
with httpx.Client(timeout=10) as client:
response = client.post(full_url, json=json, headers=headers)

try:
response_data = response.json()
except ValueError:
response_data = {"errorMessage": response.text.strip() or ""}

if not response.ok:
if response.status_code >= 400:
error_message = response_data.get("errorMessage", "")
raise MpesaApiException(
MpesaError(
Expand All @@ -74,23 +75,15 @@ def post(

return response_data

except requests.Timeout:
except httpx.TimeoutException:
raise MpesaApiException(
MpesaError(
error_code="REQUEST_TIMEOUT",
error_message="Request to Mpesa timed out.",
status_code=None,
)
)
except requests.ConnectionError:
raise MpesaApiException(
MpesaError(
error_code="CONNECTION_ERROR",
error_message="Failed to connect to Mpesa API. Check network or URL.",
status_code=None,
)
)
except requests.RequestException as e:
except httpx.RequestError as e:
raise MpesaApiException(
MpesaError(
error_code="REQUEST_FAILED",
Expand Down Expand Up @@ -124,16 +117,15 @@ def get(
headers = {}
full_url = f"{self.base_url}{url}"

response = requests.get(
full_url, params=params, headers=headers, timeout=10
) # Add timeout
with httpx.Client(timeout=10) as client:
response = client.get(full_url, params=params, headers=headers)

try:
response_data = response.json()
except ValueError:
response_data = {"errorMessage": response.text.strip() or ""}

if not response.ok:
if not response.is_success:
error_message = response_data.get("errorMessage", "")
raise MpesaApiException(
MpesaError(
Expand All @@ -146,23 +138,15 @@ def get(

return response_data

except requests.Timeout:
except httpx.TimeoutException:
raise MpesaApiException(
MpesaError(
error_code="REQUEST_TIMEOUT",
error_message="Request to Mpesa timed out.",
status_code=None,
)
)
except requests.ConnectionError:
raise MpesaApiException(
MpesaError(
error_code="CONNECTION_ERROR",
error_message="Failed to connect to Mpesa API. Check network or URL.",
status_code=None,
)
)
except requests.RequestException as e:
except httpx.RequestError as e:
raise MpesaApiException(
MpesaError(
error_code="REQUEST_FAILED",
Expand Down
30 changes: 16 additions & 14 deletions tests/integration/mpesa_express/test_stk_push_e2e.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@
import os
import time
import pytest
import requests
import httpx
from threading import Thread
from dotenv import load_dotenv

Expand Down Expand Up @@ -82,7 +82,8 @@ def test_stk_push_full_e2e_with_query(stk_service, fastapi_server, ngrok_tunnel)
print("🔗 Starting E2E Test: STK Push, Callback, and Query")
# 1. Clear previous callbacks
callback_base_url = f"{ngrok_tunnel}/mpesa/callback"
requests.post(f"{callback_base_url}/clear")
with httpx.Client() as client:
client.post(f"{callback_base_url}/clear")

callback_url = f"{ngrok_tunnel}/mpesa/callback"
print(f"📨 Using callback URL: {callback_url}")
Expand Down Expand Up @@ -111,18 +112,19 @@ def test_stk_push_full_e2e_with_query(stk_service, fastapi_server, ngrok_tunnel)
print("⏳ Waiting up to 30 seconds for callback...")
callback_received = False
callback = None
for _ in range(30):
time.sleep(1)
r = requests.get(f"{callback_base_url}/latest", timeout=45)
if r.status_code == 200:
callback_received = True
callback_json = r.json()["parsed"]
callback = StkPushSimulateCallback.model_validate(callback_json)
body = callback.Body.stkCallback
print(
f"🎉 Callback received: ResultCode={body.ResultCode}, Desc={body.ResultDesc}"
)
break
with httpx.Client() as client:
for _ in range(30):
time.sleep(1)
r = client.get(f"{callback_base_url}/latest", timeout=45)
if r.status_code == 200:
callback_received = True
callback_json = r.json()["parsed"]
callback = StkPushSimulateCallback.model_validate(callback_json)
body = callback.Body.stkCallback
print(
f"🎉 Callback received: ResultCode={body.ResultCode}, Desc={body.ResultDesc}"
)
break

if not callback_received:
print(
Expand Down
44 changes: 20 additions & 24 deletions tests/unit/http_client/test_mpesa_http_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
HTTP POST and GET request handling, and error handling for various scenarios.
"""

import requests
import httpx
import pytest
from unittest.mock import Mock, patch
from mpesakit.http_client.mpesa_http_client import MpesaHttpClient
Expand Down Expand Up @@ -32,9 +32,9 @@ def test_base_url_production():

def test_post_success(client):
"""Test successful POST request returns expected JSON."""
with patch("mpesakit.http_client.mpesa_http_client.requests.post") as mock_post:
with patch("mpesakit.http_client.mpesa_http_client.httpx.Client.post") as mock_post:
mock_response = Mock()
mock_response.ok = True
mock_response.status_code = 200
mock_response.json.return_value = {"foo": "bar"}
mock_post.return_value = mock_response

Expand All @@ -45,9 +45,8 @@ def test_post_success(client):

def test_post_http_error(client):
"""Test POST request returns MpesaApiException on HTTP error."""
with patch("mpesakit.http_client.mpesa_http_client.requests.post") as mock_post:
with patch("mpesakit.http_client.mpesa_http_client.httpx.Client.post") as mock_post:
mock_response = Mock()
mock_response.ok = False
mock_response.status_code = 400
mock_response.json.return_value = {"errorMessage": "Bad Request"}
mock_post.return_value = mock_response
Expand All @@ -60,9 +59,8 @@ def test_post_http_error(client):

def test_post_json_decode_error(client):
"""Test POST request handles JSON decode error gracefully."""
with patch("mpesakit.http_client.mpesa_http_client.requests.post") as mock_post:
with patch("mpesakit.http_client.mpesa_http_client.httpx.Client.post") as mock_post:
mock_response = Mock()
mock_response.ok = False
mock_response.status_code = 500
mock_response.json.side_effect = ValueError()
mock_response.text = "Internal Server Error"
Expand All @@ -77,8 +75,8 @@ def test_post_json_decode_error(client):
def test_post_request_exception(client):
"""Test POST request raises MpesaApiException on generic exception."""
with patch(
"mpesakit.http_client.mpesa_http_client.requests.post",
side_effect=requests.RequestException("boom"),
"mpesakit.http_client.mpesa_http_client.httpx.Client.post",
side_effect=httpx.RequestError("boom"),
):
with pytest.raises(MpesaApiException) as exc:
client.post("/fail", json={}, headers={})
Expand All @@ -88,8 +86,8 @@ def test_post_request_exception(client):
def test_post_timeout(client):
"""Test POST request raises MpesaApiException on timeout."""
with patch(
"mpesakit.http_client.mpesa_http_client.requests.post",
side_effect=requests.Timeout,
"mpesakit.http_client.mpesa_http_client.httpx.Client.post",
side_effect=httpx.TimeoutException,
):
with pytest.raises(MpesaApiException) as exc:
client.post("/timeout", json={}, headers={})
Expand All @@ -99,8 +97,8 @@ def test_post_timeout(client):
def test_post_connection_error(client):
"""Test POST request raises MpesaApiException on connection error."""
with patch(
"mpesakit.http_client.mpesa_http_client.requests.post",
side_effect=requests.ConnectionError,
"mpesakit.http_client.mpesa_http_client.httpx.Client.post",
side_effect=httpx.ConnectError,
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical

Fix httpx.ConnectError instantiation.

httpx.ConnectError requires a message parameter in its constructor. The mock side_effect is missing this required parameter, causing the test to fail.

Apply this diff:

-        side_effect=httpx.ConnectError,
+        side_effect=httpx.ConnectError("Connection failed"),
🤖 Prompt for AI Agents
In tests/unit/http_client/test_mpesa_http_client.py around line 101 the mock
sets side_effect=httpx.ConnectError but httpx.ConnectError requires a message
parameter; replace the side_effect with a properly instantiated exception (for
example side_effect=httpx.ConnectError("connection failed") or a callable that
raises httpx.ConnectError with a descriptive message) so the mock raises a valid
ConnectError instance.

):
with pytest.raises(MpesaApiException) as exc:
client.post("/conn", json={}, headers={})
Expand All @@ -109,7 +107,7 @@ def test_post_connection_error(client):

def test_get_success(client):
"""Test successful GET request returns expected JSON."""
with patch("mpesakit.http_client.mpesa_http_client.requests.get") as mock_get:
with patch("mpesakit.http_client.mpesa_http_client.httpx.Client.get") as mock_get:
mock_response = Mock()
mock_response.status_code = 200
mock_response.json.return_value = {"foo": "bar"}
Expand All @@ -122,9 +120,8 @@ def test_get_success(client):

def test_get_http_error(client):
"""Test GET request returns MpesaApiException on HTTP error."""
with patch("mpesakit.http_client.mpesa_http_client.requests.get") as mock_get:
with patch("mpesakit.http_client.mpesa_http_client.httpx.Client.get") as mock_get:
mock_response = Mock()
mock_response.ok = False
mock_response.status_code = 404
mock_response.json.return_value = {"errorMessage": "Not Found"}
mock_get.return_value = mock_response
Comment on lines 124 to 127
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical

Add is_success attribute to mock response.

The implementation at mpesakit/http_client/mpesa_http_client.py line 128 checks response.is_success, but the mock response doesn't set this attribute, causing the test to fail per the pipeline failure log.

Apply this diff:

         mock_response = Mock()
         mock_response.status_code = 404
+        mock_response.is_success = False
         mock_response.json.return_value = {"errorMessage": "Not Found"}
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
mock_response = Mock()
mock_response.ok = False
mock_response.status_code = 404
mock_response.json.return_value = {"errorMessage": "Not Found"}
mock_get.return_value = mock_response
mock_response = Mock()
mock_response.status_code = 404
mock_response.is_success = False
mock_response.json.return_value = {"errorMessage": "Not Found"}
mock_get.return_value = mock_response
🤖 Prompt for AI Agents
In tests/unit/http_client/test_mpesa_http_client.py around lines 124 to 127, the
mock response lacks the is_success attribute expected by
mpesakit/http_client/mpesa_http_client.py; set mock_response.is_success = False
(for this 404 case) so the test mimics the real response shape; for completeness
ensure other mocked responses in the file set is_success=True/False consistently
to match expected status branches.

Expand All @@ -137,9 +134,8 @@ def test_get_http_error(client):

def test_get_json_decode_error(client):
"""Test GET request handles JSON decode error gracefully."""
with patch("mpesakit.http_client.mpesa_http_client.requests.get") as mock_get:
with patch("mpesakit.http_client.mpesa_http_client.httpx.Client.get") as mock_get:
mock_response = Mock()
mock_response.ok = False
mock_response.status_code = 500
mock_response.json.side_effect = ValueError()
mock_response.text = "Internal Server Error"
Expand All @@ -154,8 +150,8 @@ def test_get_json_decode_error(client):
def test_get_request_exception(client):
"""Test GET request raises MpesaApiException on generic exception."""
with patch(
"mpesakit.http_client.mpesa_http_client.requests.get",
side_effect=requests.RequestException("boom"),
"mpesakit.http_client.mpesa_http_client.httpx.Client.get",
side_effect=httpx.RequestError("boom"),
):
with pytest.raises(MpesaApiException) as exc:
client.get("/fail")
Expand All @@ -165,8 +161,8 @@ def test_get_request_exception(client):
def test_get_timeout(client):
"""Test GET request raises MpesaApiException on timeout."""
with patch(
"mpesakit.http_client.mpesa_http_client.requests.get",
side_effect=requests.Timeout,
"mpesakit.http_client.mpesa_http_client.httpx.Client.get",
side_effect=httpx.TimeoutException,
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical

Fix httpx.TimeoutException instantiation.

httpx.TimeoutException requires a message parameter in its constructor. The mock side_effect is missing this required parameter, causing the test to fail.

Apply this diff:

-        side_effect=httpx.TimeoutException,
+        side_effect=httpx.TimeoutException("Connection timeout"),
🤖 Prompt for AI Agents
In tests/unit/http_client/test_mpesa_http_client.py around line 165, the mock
side_effect uses httpx.TimeoutException without the required message argument;
update the mock to raise an instantiated exception with a message (e.g., use
side_effect=httpx.TimeoutException("timeout") or a lambda that raises
httpx.TimeoutException("timeout")) so the constructor receives the required
`message` parameter.

):
with pytest.raises(MpesaApiException) as exc:
client.get("/timeout")
Expand All @@ -176,8 +172,8 @@ def test_get_timeout(client):
def test_get_connection_error(client):
"""Test GET request raises MpesaApiException on connection error."""
with patch(
"mpesakit.http_client.mpesa_http_client.requests.get",
side_effect=requests.ConnectionError,
"mpesakit.http_client.mpesa_http_client.httpx.Client.get",
side_effect=httpx.ConnectError,
):
with pytest.raises(MpesaApiException) as exc:
client.get("/conn")
Expand Down
Loading