Skip to content
Merged
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
5 changes: 5 additions & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,11 @@ dependencies = [
"python-dotenv"
]

[project.optional-dependencies]
test = [
"pytest"
]

[project.urls]
Homepage = "https://github.com/samdoghor/python-termii"
Repository = "https://github.com/samdoghor/python-termii"
Expand Down
2 changes: 1 addition & 1 deletion termii_py/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,4 +7,4 @@
from .client import TermiiClient

__all__ = ["TermiiClient"]
__version__ = "0.1.0"
__version__ = "0.1.2"
22 changes: 8 additions & 14 deletions termii_py/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -44,9 +44,15 @@ def __init__(self, api_key: str = None, base_url: str = None):
self.base_url = base_url or config.TERMII_BASE_URL

if not self.api_key:
raise ValueError("api_key is required. Pass it directly or set TERMII_API_KEY in your environment.")
raise ClientConfigError(
"Missing TERMII_API_KEY. Provide via api_key parameter or TERMII_API_KEY environment variable. "
"Get your API key at: https://app.termii.com/"
)
if not self.base_url:
raise ValueError("base_url is required. Pass it directly or set TERMII_BASE_URL in your environment.")
raise ClientConfigError(
"Missing TERMII_BASE_URL. Provide via base_url parameter or TERMII_BASE_URL environment variable. "
"Get your base url at: https://app.termii.com/"
)

self.http = RequestHandler(self.api_key, self.base_url)
self.sender_id = SenderIDService(self.http)
Expand All @@ -56,15 +62,3 @@ def __init__(self, api_key: str = None, base_url: str = None):
self.phonebook = PhonebookService(self.http)
self.contact = ContactService(self.http)
self.campaign = CampaignService(self.http)

if not self.api_key:
raise ClientConfigError(
"Missing TERMII_API_KEY. Provide via api_key parameter or TERMII_API_KEY environment variable. "
"Get your API key at: https://app.termii.com/"
)

if not self.base_url:
raise ClientConfigError(
"Missing TERMII_BASE_URL. Provide via base_url parameter or TERMII_BASE_URL environment variable. "
"Get your base url at: https://app.termii.com/"
)
6 changes: 4 additions & 2 deletions termii_py/http/request_handler.py
Original file line number Diff line number Diff line change
Expand Up @@ -135,7 +135,9 @@ def post_file(self, endpoint, file_path: str, data: dict):

data["api_key"] = self.api_key

files = {"file": open(file_path, "rb")}
response = requests.post(f"{self.base_url}{endpoint}", files=files, data=data)
with open(file_path, "rb") as file_handle:
files = {"file": file_handle}
response = requests.post(
f"{self.base_url}{endpoint}", files=files, data=data)

return RequestResponse.handle_response(response)
40 changes: 23 additions & 17 deletions termii_py/services/message.py
Original file line number Diff line number Diff line change
Expand Up @@ -74,19 +74,20 @@ def send_message(self, sent_to: str, sent_from: str, message: str, channel: str,
"""

PhoneNumber(sent_to)
normalized_channel = str(channel).strip().lower()
normalized_type = str(type).strip().lower()

if compare_digest("whatsapp", str(channel).strip().lower()):
raise ValueError("For WhatsApp messages, please use the 'send_whatsapp_message' method.")
if compare_digest("whatsapp", normalized_channel):
raise ValueError(
"For WhatsApp messages, please use the 'send_whatsapp_message' method.")

if compare_digest("voice", str(channel).strip().lower()) and not compare_digest("voice",
str(type).strip().lower()):
raise ValueError("For voice channel, the 'type' parameter must be set to 'voice'.")
if compare_digest("voice", normalized_channel) and not compare_digest("voice", normalized_type):
raise ValueError(
"For voice channel, the 'type' parameter must be set to 'voice'.")

if not compare_digest("generic", str(channel).strip().lower()) and not compare_digest("dnd",
str(channel).strip().lower() or not compare_digest(
"voice",
str(channel).strip().lower())):
raise ValueError("The 'channel' parameter must be either 'generic' or 'dnd' or voice.")
if normalized_channel not in ["generic", "dnd", "voice"]:
raise ValueError(
"The 'channel' parameter must be either 'generic' or 'dnd' or voice.")

payload = {
"to": sent_to,
Expand Down Expand Up @@ -170,15 +171,20 @@ def send_bulk_message(self, sent_to: list, sent_from: str, message: str, channel
for x in sent_to:
PhoneNumber(x)

if compare_digest("whatsapp", str(channel).strip().lower()):
raise ValueError("For WhatsApp messages, please use the 'send_whatsapp_message' method.")
normalized_channel = str(channel).strip().lower()
normalized_type = str(type).strip().lower()

if compare_digest("voice", str(channel).strip().lower()) or compare_digest("voice", str(type).strip().lower()):
raise ValueError("Voice messages are not supported in bulk messaging.")
if compare_digest("whatsapp", normalized_channel):
raise ValueError(
"For WhatsApp messages, please use the 'send_whatsapp_message' method.")

if not compare_digest("generic", str(channel).strip().lower()) and not compare_digest("dnd",
str(channel).strip().lower()):
raise ValueError("The 'channel' parameter must be either 'generic' or 'dnd' or voice.")
if compare_digest("voice", normalized_channel) or compare_digest("voice", normalized_type):
raise ValueError(
"Voice messages are not supported in bulk messaging.")

if normalized_channel not in ["generic", "dnd"]:
raise ValueError(
"The 'channel' parameter must be either 'generic' or 'dnd' or voice.")

payload = {
"to": sent_to,
Expand Down
3 changes: 3 additions & 0 deletions termii_py/value_object/phone_number.py
Original file line number Diff line number Diff line change
Expand Up @@ -71,4 +71,7 @@ def is_valid_phone_number(phone_number) -> bool:
False
"""

if not isinstance(phone_number, str):
return False

return bool(re.match(r"^234\d{10}$", phone_number))
58 changes: 58 additions & 0 deletions test/test_client.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
import pytest

from termii_py.client import TermiiClient
from termii_py.utils.exception import ClientConfigError


def test_client_uses_direct_credentials(monkeypatch):
monkeypatch.setattr(
"termii_py.client.config.TERMII_API_KEY", None, raising=False)
monkeypatch.setattr(
"termii_py.client.config.TERMII_BASE_URL", None, raising=False)

client = TermiiClient(
api_key="api-key", base_url="https://termii.example/")

assert client.api_key == "api-key"
assert client.base_url == "https://termii.example/"
assert client.http.api_key == "api-key"
assert client.http.base_url == "https://termii.example"
assert client.sender_id.http is client.http
assert client.message.http is client.http
assert client.number.http is client.http
assert client.template.http is client.http
assert client.phonebook.http is client.http
assert client.contact.http is client.http
assert client.campaign.http is client.http


def test_client_uses_environment_values(monkeypatch):
monkeypatch.setattr(
"termii_py.client.config.TERMII_API_KEY", "env-api-key", raising=False)
monkeypatch.setattr("termii_py.client.config.TERMII_BASE_URL",
"https://env.termii.example/", raising=False)

client = TermiiClient()

assert client.api_key == "env-api-key"
assert client.base_url == "https://env.termii.example/"


def test_client_raises_for_missing_api_key(monkeypatch):
monkeypatch.setattr(
"termii_py.client.config.TERMII_API_KEY", None, raising=False)
monkeypatch.setattr("termii_py.client.config.TERMII_BASE_URL",
"https://env.termii.example/", raising=False)

with pytest.raises(ClientConfigError, match="TERMII_API_KEY"):
TermiiClient()


def test_client_raises_for_missing_base_url(monkeypatch):
monkeypatch.setattr(
"termii_py.client.config.TERMII_API_KEY", "env-api-key", raising=False)
monkeypatch.setattr(
"termii_py.client.config.TERMII_BASE_URL", None, raising=False)

with pytest.raises(ClientConfigError, match="TERMII_BASE_URL"):
TermiiClient()
110 changes: 110 additions & 0 deletions test/test_http.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,110 @@
from pathlib import Path
from types import SimpleNamespace
from unittest.mock import MagicMock, patch

from termii_py.http import RequestHandler, RequestResponse


def test_fetch_adds_api_key_and_strips_trailing_slash():
handler = RequestHandler("secret-key", "https://termii.example/")
response = SimpleNamespace(status_code=200, text="ok")

with patch("termii_py.http.request_handler.requests.get", return_value=response) as mock_get:
with patch("termii_py.http.request_handler.RequestResponse.handle_response", return_value="handled") as mock_handle:
result = handler.fetch("/api/items", params={"page": 1})

assert result == "handled"
mock_get.assert_called_once_with(
"https://termii.example/api/items",
params={"page": 1, "api_key": "secret-key"},
)
mock_handle.assert_called_once_with(response)


def test_post_adds_api_key():
handler = RequestHandler("secret-key", "https://termii.example")
response = SimpleNamespace(status_code=201, text="created")

with patch("termii_py.http.request_handler.requests.post", return_value=response) as mock_post:
with patch("termii_py.http.request_handler.RequestResponse.handle_response", return_value="handled") as mock_handle:
result = handler.post("/api/items", json={"name": "demo"})

assert result == "handled"
mock_post.assert_called_once_with(
"https://termii.example/api/items",
json={"name": "demo", "api_key": "secret-key"},
)
mock_handle.assert_called_once_with(response)


def test_patch_adds_api_key():
handler = RequestHandler("secret-key", "https://termii.example")
response = SimpleNamespace(status_code=200, text="updated")

with patch("termii_py.http.request_handler.requests.patch", return_value=response) as mock_patch:
with patch("termii_py.http.request_handler.RequestResponse.handle_response", return_value="handled") as mock_handle:
result = handler.patch("/api/items/1", json={"name": "demo"})

assert result == "handled"
mock_patch.assert_called_once_with(
"https://termii.example/api/items/1",
json={"name": "demo", "api_key": "secret-key"},
)
mock_handle.assert_called_once_with(response)


def test_delete_adds_api_key():
handler = RequestHandler("secret-key", "https://termii.example")
response = SimpleNamespace(status_code=200, text="deleted")

with patch("termii_py.http.request_handler.requests.delete", return_value=response) as mock_delete:
with patch("termii_py.http.request_handler.RequestResponse.handle_response", return_value="handled") as mock_handle:
result = handler.delete("/api/items/1", params={"force": True})

assert result == "handled"
mock_delete.assert_called_once_with(
"https://termii.example/api/items/1",
params={"force": True, "api_key": "secret-key"},
)
mock_handle.assert_called_once_with(response)


def test_post_file_uses_file_upload_and_closes_file(tmp_path: Path):
handler = RequestHandler("secret-key", "https://termii.example")
file_path = tmp_path / "contacts.csv"
file_path.write_text("phone_number\n2348012345678\n", encoding="utf-8")
response = SimpleNamespace(status_code=200, text="uploaded")

with patch("termii_py.http.request_handler.requests.post", return_value=response) as mock_post:
with patch("termii_py.http.request_handler.RequestResponse.handle_response", return_value="handled") as mock_handle:
result = handler.post_file(
"/api/upload", str(file_path), {"phonebook_id": "abc123"})

assert result == "handled"
files = mock_post.call_args.kwargs["files"]
data = mock_post.call_args.kwargs["data"]
assert data == {"phonebook_id": "abc123", "api_key": "secret-key"}
assert files["file"].closed is True
mock_handle.assert_called_once_with(response)


def test_request_response_marks_success_and_error():
ok_response = SimpleNamespace(
status_code=200, text="{""status"": ""success""}")
created_response = SimpleNamespace(
status_code=201, text="{""status"": ""created""}")
error_response = SimpleNamespace(status_code=400, text="bad request")

ok_result = RequestResponse.handle_response(ok_response)
created_result = RequestResponse.handle_response(created_response)
error_result = RequestResponse.handle_response(error_response)

assert ok_result.status_code == 200
assert ok_result.status == "ok"
assert ok_result.message == ok_response.text
assert created_result.status_code == 201
assert created_result.status == "ok"
assert created_result.message == created_response.text
assert error_result.status_code == 400
assert error_result.status == "error"
assert error_result.message == "bad request"
Loading
Loading