From 28484a68dd28ffe762c12ecae88bc3b87d7b0ce2 Mon Sep 17 00:00:00 2001 From: Samuel Doghor <56834362+samdoghor@users.noreply.github.com> Date: Fri, 3 Apr 2026 12:59:27 +0100 Subject: [PATCH 1/2] test: add comprehensive coverage for Termii SDK --- pyproject.toml | 5 + requirements.txt | 31 ++ termii_py/__init__.py | 2 +- termii_py/client.py | 22 +- termii_py/http/request_handler.py | 6 +- termii_py/services/message.py | 40 +- termii_py/value_object/phone_number.py | 3 + test/test_client.py | 58 +++ test/test_http.py | 110 ++++++ test/test_services.py | 515 +++++++++++++++++++++++++ test/test_value_object.py | 35 ++ 11 files changed, 793 insertions(+), 34 deletions(-) create mode 100644 requirements.txt create mode 100644 test/test_client.py create mode 100644 test/test_http.py create mode 100644 test/test_services.py create mode 100644 test/test_value_object.py diff --git a/pyproject.toml b/pyproject.toml index 989fc43..5a24ff3 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -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" diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..023ade0 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,31 @@ +build==1.4.0 +certifi==2026.2.25 +charset-normalizer==3.4.4 +colorama==0.4.6 +docutils==0.22.4 +id==1.6.1 +idna==3.11 +iniconfig==2.3.0 +jaraco.classes==3.4.0 +jaraco.context==6.1.0 +jaraco.functools==4.4.0 +keyring==25.7.0 +markdown-it-py==4.0.0 +mdurl==0.1.2 +more-itertools==10.8.0 +nh3==0.3.3 +packaging==26.0 +pluggy==1.6.0 +Pygments==2.19.2 +pyproject_hooks==1.2.0 +pytest==9.0.2 +python-dotenv==1.2.2 +python-termii +pywin32-ctypes==0.2.3 +readme_renderer==44.0 +requests==2.32.5 +requests-toolbelt==1.0.0 +rfc3986==2.0.0 +rich==14.3.3 +twine==6.2.0 +urllib3==2.6.3 diff --git a/termii_py/__init__.py b/termii_py/__init__.py index 40eaa86..636eb09 100644 --- a/termii_py/__init__.py +++ b/termii_py/__init__.py @@ -7,4 +7,4 @@ from .client import TermiiClient __all__ = ["TermiiClient"] -__version__ = "0.1.0" +__version__ = "0.1.2" diff --git a/termii_py/client.py b/termii_py/client.py index cfe562d..4eb106d 100644 --- a/termii_py/client.py +++ b/termii_py/client.py @@ -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) @@ -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/" - ) diff --git a/termii_py/http/request_handler.py b/termii_py/http/request_handler.py index 2925cd0..c9a0459 100644 --- a/termii_py/http/request_handler.py +++ b/termii_py/http/request_handler.py @@ -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) diff --git a/termii_py/services/message.py b/termii_py/services/message.py index 0a7acbb..065eb4b 100644 --- a/termii_py/services/message.py +++ b/termii_py/services/message.py @@ -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, @@ -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, diff --git a/termii_py/value_object/phone_number.py b/termii_py/value_object/phone_number.py index b8ac0f4..dfb1165 100644 --- a/termii_py/value_object/phone_number.py +++ b/termii_py/value_object/phone_number.py @@ -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)) diff --git a/test/test_client.py b/test/test_client.py new file mode 100644 index 0000000..c248475 --- /dev/null +++ b/test/test_client.py @@ -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() diff --git a/test/test_http.py b/test/test_http.py new file mode 100644 index 0000000..d949ef5 --- /dev/null +++ b/test/test_http.py @@ -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" diff --git a/test/test_services.py b/test/test_services.py new file mode 100644 index 0000000..1bc259d --- /dev/null +++ b/test/test_services.py @@ -0,0 +1,515 @@ +from unittest.mock import MagicMock + +import pytest + +from termii_py.services.campaign import CampaignService +from termii_py.services.contact import ContactService +from termii_py.services.message import MessageService +from termii_py.services.number import NumberService +from termii_py.services.phonebook import PhonebookService +from termii_py.services.sender_id import SenderIDService +from termii_py.services.template import TemplateService + + +def make_http_mock(): + return MagicMock() + + +def test_sender_id_fetch_id_uses_optional_filters(): + http = make_http_mock() + http.fetch.return_value = "ok" + service = SenderIDService(http) + + result = service.fetch_id(name="MyBrand", status="approved") + + assert result == "ok" + http.fetch.assert_called_once_with( + "/api/sender-id", params={"name": "MyBrand", "status": "approved"}) + + +def test_sender_id_request_id_builds_payload(): + http = make_http_mock() + http.post.return_value = "ok" + service = SenderIDService(http) + + result = service.request_id("MyBrand", "Transactional alerts", "Acme Ltd") + + assert result == "ok" + http.post.assert_called_once_with( + "/api/sender-id/request", + json={"sender_id": "MyBrand", + "usecase": "Transactional alerts", "company": "Acme Ltd"}, + ) + + +def test_message_send_message_allows_generic_and_voice(): + http = make_http_mock() + http.post.return_value = "ok" + service = MessageService(http) + + result = service.send_message( + sent_to="2348012345678", + sent_from="MyBrand", + message="Your order has been confirmed.", + channel="generic", + type="plain", + ) + + assert result == "ok" + http.post.assert_called_once_with( + "/api/sms/send", + json={ + "to": "2348012345678", + "from": "MyBrand", + "sms": "Your order has been confirmed.", + "channel": "generic", + "type": "plain", + }, + ) + + +def test_message_send_message_allows_voice_when_type_matches(): + http = make_http_mock() + http.post.return_value = "ok" + service = MessageService(http) + + result = service.send_message( + sent_to="2348012345678", + sent_from="MyBrand", + message="Press 1 to confirm.", + channel="voice", + type="voice", + ) + + assert result == "ok" + http.post.assert_called_once() + + +@pytest.mark.parametrize( + "channel, message_type, error_message", + [ + ("whatsapp", "plain", "For WhatsApp messages"), + ("voice", "plain", "For voice channel"), + ("invalid", "plain", "must be either 'generic' or 'dnd' or voice"), + ], +) +def test_message_send_message_validates_channel(channel, message_type, error_message): + http = make_http_mock() + service = MessageService(http) + + with pytest.raises(ValueError, match=error_message): + service.send_message( + sent_to="2348012345678", + sent_from="MyBrand", + message="Hello", + channel=channel, + type=message_type, + ) + + +def test_message_send_whatsapp_message_builds_payload(): + http = make_http_mock() + http.post.return_value = "ok" + service = MessageService(http) + + result = service.send_whatsapp_message( + sent_to="2348012345678", + sent_from="MyBrand", + message="Hello!", + ) + + assert result == "ok" + http.post.assert_called_once_with( + "/api/sms/send", + json={ + "to": "2348012345678", + "from": "MyBrand", + "sms": "Hello!", + "channel": "whatsapp", + "type": "plain", + "media": {"url": None, "caption": None}, + }, + ) + + +def test_message_send_bulk_message_builds_payload(): + http = make_http_mock() + http.post.return_value = "ok" + service = MessageService(http) + + result = service.send_bulk_message( + sent_to=["2348012345678", "2348098765432"], + sent_from="MyBrand", + message="Promo", + channel="dnd", + type="plain", + ) + + assert result == "ok" + http.post.assert_called_once_with( + "/api/sms/send/bulk", + json={ + "to": ["2348012345678", "2348098765432"], + "from": "MyBrand", + "sms": "Promo", + "channel": "dnd", + "type": "plain", + }, + ) + + +@pytest.mark.parametrize( + "channel, message_type, error_message", + [ + ("whatsapp", "plain", "For WhatsApp messages"), + ("voice", "plain", "Voice messages are not supported in bulk messaging."), + ("generic", "voice", "Voice messages are not supported in bulk messaging."), + ("invalid", "plain", "must be either 'generic' or 'dnd' or voice"), + ], +) +def test_message_send_bulk_message_validates_channel(channel, message_type, error_message): + http = make_http_mock() + service = MessageService(http) + + with pytest.raises(ValueError, match=error_message): + service.send_bulk_message( + sent_to=["2348012345678"], + sent_from="MyBrand", + message="Promo", + channel=channel, + type=message_type, + ) + + +def test_number_send_message_builds_payload(): + http = make_http_mock() + http.post.return_value = "ok" + service = NumberService(http) + + result = service.send_message( + sent_to="2348012345678", message="Your code is 123456") + + assert result == "ok" + http.post.assert_called_once_with( + "/api/sms/number/send", + json={"to": "2348012345678", "sms": "Your code is 123456"}, + ) + + +def test_template_send_message_builds_text_payload(): + http = make_http_mock() + http.post.return_value = "ok" + service = TemplateService(http) + + result = service.send_message( + sent_to="2348012345678", + device_id="device-1", + template_id="template-1", + data={"name": "Ada"}, + ) + + assert result == "ok" + http.post.assert_called_once_with( + "/api/send/template", + json={ + "phone_number": "2348012345678", + "device_id": "device-1", + "template_id": "template-1", + "data": {"name": "Ada"}, + }, + ) + + +def test_template_send_message_builds_media_payload(): + http = make_http_mock() + http.post.return_value = "ok" + service = TemplateService(http) + + result = service.send_message( + sent_to="2348012345678", + device_id="device-1", + template_id="template-1", + data={"name": "Ada"}, + caption="Receipt", + url="https://example.com/receipt.pdf", + ) + + assert result == "ok" + http.post.assert_called_once_with( + "/api/send/template/media", + json={ + "phone_number": "2348012345678", + "device_id": "device-1", + "template_id": "template-1", + "data": {"name": "Ada"}, + "media": {"caption": "Receipt", "url": "https://example.com/receipt.pdf"}, + }, + ) + + +@pytest.mark.parametrize( + "caption, url, error_message", + [ + ("Receipt", None, "If caption is provided, url must also be provided"), + (None, "https://example.com/receipt.pdf", + "If url is provided, caption must also be provided"), + (None, None, None), + ], +) +def test_template_send_message_validates_media_arguments(caption, url, error_message): + http = make_http_mock() + service = TemplateService(http) + + if error_message: + with pytest.raises(ValueError, match=error_message): + service.send_message( + sent_to="2348012345678", + device_id="device-1", + template_id="template-1", + data={"name": "Ada"}, + caption=caption, + url=url, + ) + else: + service.send_message( + sent_to="2348012345678", + device_id="device-1", + template_id="template-1", + data={"name": "Ada"}, + ) + + +def test_template_send_message_requires_dict_data(): + http = make_http_mock() + service = TemplateService(http) + + with pytest.raises(ValueError, match="The 'data' parameter must be a dictionary"): + service.send_message( + sent_to="2348012345678", + device_id="device-1", + template_id="template-1", + data=["not", "a", "dict"], + ) + + +def test_phonebook_methods_delegate_to_http(): + http = make_http_mock() + http.fetch.return_value = "fetched" + http.post.return_value = "created" + http.patch.return_value = "updated" + http.delete.return_value = "deleted" + service = PhonebookService(http) + + assert service.fetch_phonebooks() == "fetched" + assert service.create_phonebooks( + "Newsletter", "Weekly updates") == "created" + assert service.update_phonebook( + "pb-1", "VIP", "Top customers") == "updated" + assert service.delete_phonebook("pb-1") == "deleted" + + http.fetch.assert_called_once_with("/api/phonebooks") + http.post.assert_called_once_with( + "/api/phonebooks", + json={"phonebook_name": "Newsletter", "description": "Weekly updates"}, + ) + http.patch.assert_called_once_with( + "/api/phonebooks/pb-1", + json={"phonebook_name": "VIP", "description": "Top customers"}, + ) + http.delete.assert_called_once_with("/api/phonebooks/pb-1") + + +def test_phonebook_update_and_delete_require_ids(): + http = make_http_mock() + service = PhonebookService(http) + + with pytest.raises(ValueError, match="phonebook_id is required to update a phonebook"): + service.update_phonebook("", "VIP", "Top customers") + + with pytest.raises(ValueError, match="phonebook_id is required to delete a phonebook"): + service.delete_phonebook(None) + + +def test_contact_methods_delegate_to_http(): + http = make_http_mock() + http.fetch.return_value = "contacts" + http.post.return_value = "created" + http.post_file.return_value = "uploaded" + http.delete.return_value = "deleted" + service = ContactService(http) + + assert service.fetch_contacts("pb-1") == "contacts" + assert service.create_contact( + "pb-1", + phone_number="8012345678", + country_code="234", + email_address="ada@example.com", + first_name="Ada", + last_name="Obi", + company="Acme Ltd", + ) == "created" + assert service.create_multiple_contacts( + "pb-1", "234", "contacts.csv") == "uploaded" + assert service.delete_contact("pb-1") == "deleted" + + http.fetch.assert_called_once_with("/api/phonebooks/pb-1/contacts") + http.post.assert_called_once_with( + "/api/phonebooks/pb-1/contacts", + json={ + "phone_number": "8012345678", + "country_code": "234", + "email_address": "ada@example.com", + "first_name": "Ada", + "last_name": "Obi", + "company": "Acme Ltd", + }, + ) + http.post_file.assert_called_once_with( + "/api/phonebooks/contacts/upload", + data={"phonebook_id": "pb-1", "country_code": "234"}, + file_path="contacts.csv", + ) + http.delete.assert_called_once_with("/api/phonebooks/pb-1/contacts") + + +@pytest.mark.parametrize( + "method_name, args, error_message", + [ + ("fetch_contacts", {"phonebook_id": ""}, "phonebook_id is required"), + ("create_contact", {"phonebook_id": "", "phone_number": "8012345678", + "country_code": "234"}, "phonebook_id is required"), + ("create_contact", {"phonebook_id": "pb-1", "phone_number": "8012345678", + "country_code": "+234"}, "country code should not start"), + ("create_multiple_contacts", {"phonebook_id": "", "country_code": "234", + "file_path": "contacts.csv"}, "phonebook_id is required"), + ("create_multiple_contacts", {"phonebook_id": "pb-1", "country_code": "+234", + "file_path": "contacts.csv"}, "country code should not start"), + ("delete_contact", {"phonebook_id": ""}, + "phonebook_id is required to delete contacts"), + ], +) +def test_contact_methods_validate_inputs(method_name, args, error_message): + http = make_http_mock() + service = ContactService(http) + + with pytest.raises(ValueError, match=error_message): + getattr(service, method_name)(**args) + + +def test_campaign_send_campaign_builds_payload(): + http = make_http_mock() + http.post.return_value = "ok" + service = CampaignService(http) + + result = service.send_campaign( + country_code="234", + sender_id="MyBrand", + message="Big sale", + message_type="plain", + phonebook_id="pb-1", + enable_link_tracking=False, + campaign_type="promotional", + schedule_sms_status="regular", + channel="dnd", + ) + + assert result == "ok" + http.post.assert_called_once_with( + "/api/sms/campaigns/send", + { + "country_code": "234", + "sender_id": "MyBrand", + "message": "Big sale", + "message_type": "plain", + "phonebook_id": "pb-1", + "enable_link_tracking": False, + "campaign_type": "promotional", + "schedule_sms_status": "regular", + "schedule_time": None, + "channel": "dnd", + "remove_duplicate": "yes", + "delimiter": ",", + }, + ) + + +def test_campaign_send_campaign_requires_schedule_time_when_scheduled(): + http = make_http_mock() + service = CampaignService(http) + + with pytest.raises(ValueError, match="schedule time is required"): + service.send_campaign( + country_code="234", + sender_id="MyBrand", + message="Big sale", + message_type="plain", + phonebook_id="pb-1", + enable_link_tracking=False, + campaign_type="promotional", + schedule_sms_status="scheduled", + channel="dnd", + ) + + +@pytest.mark.parametrize( + "kwargs, error_message", + [ + ({"country_code": "+234"}, "country code should not start"), + ({"sender_id": "AB"}, "sender id should be between 3 and 11 characters"), + ({"message_type": "emoji"}, + "message type should be either 'plain' or 'unicode'"), + ({"channel": "voice"}, "channel should be either 'dnd' or 'generic'"), + ({"schedule_sms_status": "later"}, + "schedule sms status should be either 'scheduled' or 'regular'"), + ], +) +def test_campaign_send_campaign_validates_inputs(kwargs, error_message): + http = make_http_mock() + service = CampaignService(http) + + base_kwargs = { + "country_code": "234", + "sender_id": "MyBrand", + "message": "Big sale", + "message_type": "plain", + "phonebook_id": "pb-1", + "enable_link_tracking": False, + "campaign_type": "promotional", + "schedule_sms_status": "regular", + "channel": "dnd", + } + base_kwargs.update(kwargs) + + with pytest.raises(ValueError, match=error_message): + service.send_campaign(**base_kwargs) + + +def test_campaign_fetch_and_retry_methods_delegate_to_http(): + http = make_http_mock() + http.fetch.return_value = "campaigns" + http.patch.return_value = "retried" + service = CampaignService(http) + + assert service.fetch_campaigns() == "campaigns" + assert service.fetch_campaign_history("camp-1") == "campaigns" + assert service.retry_campaign("camp-1") == "retried" + + http.fetch.assert_any_call("/api/sms/campaigns") + http.fetch.assert_any_call("/api/sms/campaigns/camp-1") + http.patch.assert_called_once_with("/api/sms/campaigns/camp-1", json={}) + + +@pytest.mark.parametrize( + "method_name, kwargs, error_message", + [ + ("fetch_campaign_history", { + "campaign_id": ""}, "campaign id is required"), + ("retry_campaign", {"campaign_id": None}, "campaign id is required"), + ], +) +def test_campaign_campaign_id_is_required(method_name, kwargs, error_message): + http = make_http_mock() + service = CampaignService(http) + + with pytest.raises(ValueError, match=error_message): + getattr(service, method_name)(**kwargs) diff --git a/test/test_value_object.py b/test/test_value_object.py new file mode 100644 index 0000000..bb68f74 --- /dev/null +++ b/test/test_value_object.py @@ -0,0 +1,35 @@ +import pytest + +from termii_py.value_object import PhoneNumber + + +@pytest.mark.parametrize( + "phone_number", + [ + "2348012345678", + "2349012345678", + ], +) +def test_phone_number_accepts_valid_msisdn(phone_number): + value = PhoneNumber(phone_number) + + assert value.phone_number == phone_number + assert PhoneNumber.is_valid_phone_number(phone_number) is True + + +@pytest.mark.parametrize( + "phone_number", + [ + "08012345678", + "234801234567", + "23480123456789", + "23480abcdef12", + "", + None, + ], +) +def test_phone_number_rejects_invalid_values(phone_number): + with pytest.raises(ValueError, match="Invalid phone number"): + PhoneNumber(phone_number) + + assert PhoneNumber.is_valid_phone_number(phone_number) is False From 4094000e16443592439b2c9c8c5d50915e08f72d Mon Sep 17 00:00:00 2001 From: Samuel Doghor <56834362+samdoghor@users.noreply.github.com> Date: Fri, 3 Apr 2026 13:11:29 +0100 Subject: [PATCH 2/2] chore: remove requirements.txt to resolve dependency build error --- requirements.txt | 31 ------------------------------- 1 file changed, 31 deletions(-) delete mode 100644 requirements.txt diff --git a/requirements.txt b/requirements.txt deleted file mode 100644 index 023ade0..0000000 --- a/requirements.txt +++ /dev/null @@ -1,31 +0,0 @@ -build==1.4.0 -certifi==2026.2.25 -charset-normalizer==3.4.4 -colorama==0.4.6 -docutils==0.22.4 -id==1.6.1 -idna==3.11 -iniconfig==2.3.0 -jaraco.classes==3.4.0 -jaraco.context==6.1.0 -jaraco.functools==4.4.0 -keyring==25.7.0 -markdown-it-py==4.0.0 -mdurl==0.1.2 -more-itertools==10.8.0 -nh3==0.3.3 -packaging==26.0 -pluggy==1.6.0 -Pygments==2.19.2 -pyproject_hooks==1.2.0 -pytest==9.0.2 -python-dotenv==1.2.2 -python-termii -pywin32-ctypes==0.2.3 -readme_renderer==44.0 -requests==2.32.5 -requests-toolbelt==1.0.0 -rfc3986==2.0.0 -rich==14.3.3 -twine==6.2.0 -urllib3==2.6.3