python-termii 0.1.0__tar.gz → 0.1.2__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {python_termii-0.1.0/python_termii.egg-info → python_termii-0.1.2}/PKG-INFO +3 -1
- {python_termii-0.1.0 → python_termii-0.1.2}/pyproject.toml +5 -0
- {python_termii-0.1.0 → python_termii-0.1.2/python_termii.egg-info}/PKG-INFO +3 -1
- {python_termii-0.1.0 → python_termii-0.1.2}/python_termii.egg-info/SOURCES.txt +5 -1
- {python_termii-0.1.0 → python_termii-0.1.2}/python_termii.egg-info/requires.txt +3 -0
- {python_termii-0.1.0 → python_termii-0.1.2}/termii_py/__init__.py +1 -1
- {python_termii-0.1.0 → python_termii-0.1.2}/termii_py/client.py +9 -9
- {python_termii-0.1.0 → python_termii-0.1.2}/termii_py/http/request_handler.py +4 -2
- {python_termii-0.1.0 → python_termii-0.1.2}/termii_py/services/message.py +23 -17
- {python_termii-0.1.0 → python_termii-0.1.2}/termii_py/value_object/phone_number.py +3 -0
- python_termii-0.1.2/test/test_client.py +58 -0
- python_termii-0.1.2/test/test_http.py +110 -0
- python_termii-0.1.2/test/test_services.py +515 -0
- python_termii-0.1.2/test/test_value_object.py +35 -0
- {python_termii-0.1.0 → python_termii-0.1.2}/LICENSE +0 -0
- {python_termii-0.1.0 → python_termii-0.1.2}/README.md +0 -0
- {python_termii-0.1.0 → python_termii-0.1.2}/python_termii.egg-info/dependency_links.txt +0 -0
- {python_termii-0.1.0 → python_termii-0.1.2}/python_termii.egg-info/top_level.txt +0 -0
- {python_termii-0.1.0 → python_termii-0.1.2}/setup.cfg +0 -0
- {python_termii-0.1.0 → python_termii-0.1.2}/termii_py/config.py +0 -0
- {python_termii-0.1.0 → python_termii-0.1.2}/termii_py/http/__init__.py +0 -0
- {python_termii-0.1.0 → python_termii-0.1.2}/termii_py/http/request_response.py +0 -0
- {python_termii-0.1.0 → python_termii-0.1.2}/termii_py/services/__init__.py +0 -0
- {python_termii-0.1.0 → python_termii-0.1.2}/termii_py/services/campaign.py +0 -0
- {python_termii-0.1.0 → python_termii-0.1.2}/termii_py/services/contact.py +0 -0
- {python_termii-0.1.0 → python_termii-0.1.2}/termii_py/services/number.py +0 -0
- {python_termii-0.1.0 → python_termii-0.1.2}/termii_py/services/phonebook.py +0 -0
- {python_termii-0.1.0 → python_termii-0.1.2}/termii_py/services/sender_id.py +0 -0
- {python_termii-0.1.0 → python_termii-0.1.2}/termii_py/services/template.py +0 -0
- {python_termii-0.1.0 → python_termii-0.1.2}/termii_py/utils/__init__.py +0 -0
- {python_termii-0.1.0 → python_termii-0.1.2}/termii_py/utils/exception.py +0 -0
- {python_termii-0.1.0 → python_termii-0.1.2}/termii_py/value_object/__init__.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: python-termii
|
|
3
|
-
Version: 0.1.
|
|
3
|
+
Version: 0.1.2
|
|
4
4
|
Summary: A clean Python SDK for the Termii API (SMS, Voice, and OTP)
|
|
5
5
|
Author-email: Samuel Doghor Destiny <labs@amdoghor.com>
|
|
6
6
|
License-Expression: MIT
|
|
@@ -20,6 +20,8 @@ Description-Content-Type: text/markdown
|
|
|
20
20
|
License-File: LICENSE
|
|
21
21
|
Requires-Dist: requests
|
|
22
22
|
Requires-Dist: python-dotenv
|
|
23
|
+
Provides-Extra: test
|
|
24
|
+
Requires-Dist: pytest; extra == "test"
|
|
23
25
|
Dynamic: license-file
|
|
24
26
|
|
|
25
27
|
# python-termii
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: python-termii
|
|
3
|
-
Version: 0.1.
|
|
3
|
+
Version: 0.1.2
|
|
4
4
|
Summary: A clean Python SDK for the Termii API (SMS, Voice, and OTP)
|
|
5
5
|
Author-email: Samuel Doghor Destiny <labs@amdoghor.com>
|
|
6
6
|
License-Expression: MIT
|
|
@@ -20,6 +20,8 @@ Description-Content-Type: text/markdown
|
|
|
20
20
|
License-File: LICENSE
|
|
21
21
|
Requires-Dist: requests
|
|
22
22
|
Requires-Dist: python-dotenv
|
|
23
|
+
Provides-Extra: test
|
|
24
|
+
Requires-Dist: pytest; extra == "test"
|
|
23
25
|
Dynamic: license-file
|
|
24
26
|
|
|
25
27
|
# python-termii
|
|
@@ -23,4 +23,8 @@ termii_py/services/template.py
|
|
|
23
23
|
termii_py/utils/__init__.py
|
|
24
24
|
termii_py/utils/exception.py
|
|
25
25
|
termii_py/value_object/__init__.py
|
|
26
|
-
termii_py/value_object/phone_number.py
|
|
26
|
+
termii_py/value_object/phone_number.py
|
|
27
|
+
test/test_client.py
|
|
28
|
+
test/test_http.py
|
|
29
|
+
test/test_services.py
|
|
30
|
+
test/test_value_object.py
|
|
@@ -42,23 +42,23 @@ class TermiiClient:
|
|
|
42
42
|
def __init__(self, api_key: str = None, base_url: str = None):
|
|
43
43
|
self.api_key = api_key or config.TERMII_API_KEY
|
|
44
44
|
self.base_url = base_url or config.TERMII_BASE_URL
|
|
45
|
-
self.http = RequestHandler(self.api_key, self.base_url)
|
|
46
|
-
self.sender_id = SenderIDService(self.http)
|
|
47
|
-
self.message = MessageService(self.http)
|
|
48
|
-
self.number = NumberService(self.http)
|
|
49
|
-
self.template = TemplateService(self.http)
|
|
50
|
-
self.phonebook = PhonebookService(self.http)
|
|
51
|
-
self.contact = ContactService(self.http)
|
|
52
|
-
self.campaign = CampaignService(self.http)
|
|
53
45
|
|
|
54
46
|
if not self.api_key:
|
|
55
47
|
raise ClientConfigError(
|
|
56
48
|
"Missing TERMII_API_KEY. Provide via api_key parameter or TERMII_API_KEY environment variable. "
|
|
57
49
|
"Get your API key at: https://app.termii.com/"
|
|
58
50
|
)
|
|
59
|
-
|
|
60
51
|
if not self.base_url:
|
|
61
52
|
raise ClientConfigError(
|
|
62
53
|
"Missing TERMII_BASE_URL. Provide via base_url parameter or TERMII_BASE_URL environment variable. "
|
|
63
54
|
"Get your base url at: https://app.termii.com/"
|
|
64
55
|
)
|
|
56
|
+
|
|
57
|
+
self.http = RequestHandler(self.api_key, self.base_url)
|
|
58
|
+
self.sender_id = SenderIDService(self.http)
|
|
59
|
+
self.message = MessageService(self.http)
|
|
60
|
+
self.number = NumberService(self.http)
|
|
61
|
+
self.template = TemplateService(self.http)
|
|
62
|
+
self.phonebook = PhonebookService(self.http)
|
|
63
|
+
self.contact = ContactService(self.http)
|
|
64
|
+
self.campaign = CampaignService(self.http)
|
|
@@ -135,7 +135,9 @@ class RequestHandler:
|
|
|
135
135
|
|
|
136
136
|
data["api_key"] = self.api_key
|
|
137
137
|
|
|
138
|
-
|
|
139
|
-
|
|
138
|
+
with open(file_path, "rb") as file_handle:
|
|
139
|
+
files = {"file": file_handle}
|
|
140
|
+
response = requests.post(
|
|
141
|
+
f"{self.base_url}{endpoint}", files=files, data=data)
|
|
140
142
|
|
|
141
143
|
return RequestResponse.handle_response(response)
|
|
@@ -74,19 +74,20 @@ class MessageService:
|
|
|
74
74
|
"""
|
|
75
75
|
|
|
76
76
|
PhoneNumber(sent_to)
|
|
77
|
+
normalized_channel = str(channel).strip().lower()
|
|
78
|
+
normalized_type = str(type).strip().lower()
|
|
77
79
|
|
|
78
|
-
if compare_digest("whatsapp",
|
|
79
|
-
raise ValueError(
|
|
80
|
+
if compare_digest("whatsapp", normalized_channel):
|
|
81
|
+
raise ValueError(
|
|
82
|
+
"For WhatsApp messages, please use the 'send_whatsapp_message' method.")
|
|
80
83
|
|
|
81
|
-
if compare_digest("voice",
|
|
82
|
-
|
|
83
|
-
|
|
84
|
+
if compare_digest("voice", normalized_channel) and not compare_digest("voice", normalized_type):
|
|
85
|
+
raise ValueError(
|
|
86
|
+
"For voice channel, the 'type' parameter must be set to 'voice'.")
|
|
84
87
|
|
|
85
|
-
if not
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
str(channel).strip().lower())):
|
|
89
|
-
raise ValueError("The 'channel' parameter must be either 'generic' or 'dnd' or voice.")
|
|
88
|
+
if normalized_channel not in ["generic", "dnd", "voice"]:
|
|
89
|
+
raise ValueError(
|
|
90
|
+
"The 'channel' parameter must be either 'generic' or 'dnd' or voice.")
|
|
90
91
|
|
|
91
92
|
payload = {
|
|
92
93
|
"to": sent_to,
|
|
@@ -170,15 +171,20 @@ class MessageService:
|
|
|
170
171
|
for x in sent_to:
|
|
171
172
|
PhoneNumber(x)
|
|
172
173
|
|
|
173
|
-
|
|
174
|
-
|
|
174
|
+
normalized_channel = str(channel).strip().lower()
|
|
175
|
+
normalized_type = str(type).strip().lower()
|
|
175
176
|
|
|
176
|
-
if compare_digest("
|
|
177
|
-
raise ValueError(
|
|
177
|
+
if compare_digest("whatsapp", normalized_channel):
|
|
178
|
+
raise ValueError(
|
|
179
|
+
"For WhatsApp messages, please use the 'send_whatsapp_message' method.")
|
|
178
180
|
|
|
179
|
-
if
|
|
180
|
-
|
|
181
|
-
|
|
181
|
+
if compare_digest("voice", normalized_channel) or compare_digest("voice", normalized_type):
|
|
182
|
+
raise ValueError(
|
|
183
|
+
"Voice messages are not supported in bulk messaging.")
|
|
184
|
+
|
|
185
|
+
if normalized_channel not in ["generic", "dnd"]:
|
|
186
|
+
raise ValueError(
|
|
187
|
+
"The 'channel' parameter must be either 'generic' or 'dnd' or voice.")
|
|
182
188
|
|
|
183
189
|
payload = {
|
|
184
190
|
"to": sent_to,
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import pytest
|
|
2
|
+
|
|
3
|
+
from termii_py.client import TermiiClient
|
|
4
|
+
from termii_py.utils.exception import ClientConfigError
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
def test_client_uses_direct_credentials(monkeypatch):
|
|
8
|
+
monkeypatch.setattr(
|
|
9
|
+
"termii_py.client.config.TERMII_API_KEY", None, raising=False)
|
|
10
|
+
monkeypatch.setattr(
|
|
11
|
+
"termii_py.client.config.TERMII_BASE_URL", None, raising=False)
|
|
12
|
+
|
|
13
|
+
client = TermiiClient(
|
|
14
|
+
api_key="api-key", base_url="https://termii.example/")
|
|
15
|
+
|
|
16
|
+
assert client.api_key == "api-key"
|
|
17
|
+
assert client.base_url == "https://termii.example/"
|
|
18
|
+
assert client.http.api_key == "api-key"
|
|
19
|
+
assert client.http.base_url == "https://termii.example"
|
|
20
|
+
assert client.sender_id.http is client.http
|
|
21
|
+
assert client.message.http is client.http
|
|
22
|
+
assert client.number.http is client.http
|
|
23
|
+
assert client.template.http is client.http
|
|
24
|
+
assert client.phonebook.http is client.http
|
|
25
|
+
assert client.contact.http is client.http
|
|
26
|
+
assert client.campaign.http is client.http
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def test_client_uses_environment_values(monkeypatch):
|
|
30
|
+
monkeypatch.setattr(
|
|
31
|
+
"termii_py.client.config.TERMII_API_KEY", "env-api-key", raising=False)
|
|
32
|
+
monkeypatch.setattr("termii_py.client.config.TERMII_BASE_URL",
|
|
33
|
+
"https://env.termii.example/", raising=False)
|
|
34
|
+
|
|
35
|
+
client = TermiiClient()
|
|
36
|
+
|
|
37
|
+
assert client.api_key == "env-api-key"
|
|
38
|
+
assert client.base_url == "https://env.termii.example/"
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def test_client_raises_for_missing_api_key(monkeypatch):
|
|
42
|
+
monkeypatch.setattr(
|
|
43
|
+
"termii_py.client.config.TERMII_API_KEY", None, raising=False)
|
|
44
|
+
monkeypatch.setattr("termii_py.client.config.TERMII_BASE_URL",
|
|
45
|
+
"https://env.termii.example/", raising=False)
|
|
46
|
+
|
|
47
|
+
with pytest.raises(ClientConfigError, match="TERMII_API_KEY"):
|
|
48
|
+
TermiiClient()
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def test_client_raises_for_missing_base_url(monkeypatch):
|
|
52
|
+
monkeypatch.setattr(
|
|
53
|
+
"termii_py.client.config.TERMII_API_KEY", "env-api-key", raising=False)
|
|
54
|
+
monkeypatch.setattr(
|
|
55
|
+
"termii_py.client.config.TERMII_BASE_URL", None, raising=False)
|
|
56
|
+
|
|
57
|
+
with pytest.raises(ClientConfigError, match="TERMII_BASE_URL"):
|
|
58
|
+
TermiiClient()
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
from pathlib import Path
|
|
2
|
+
from types import SimpleNamespace
|
|
3
|
+
from unittest.mock import MagicMock, patch
|
|
4
|
+
|
|
5
|
+
from termii_py.http import RequestHandler, RequestResponse
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def test_fetch_adds_api_key_and_strips_trailing_slash():
|
|
9
|
+
handler = RequestHandler("secret-key", "https://termii.example/")
|
|
10
|
+
response = SimpleNamespace(status_code=200, text="ok")
|
|
11
|
+
|
|
12
|
+
with patch("termii_py.http.request_handler.requests.get", return_value=response) as mock_get:
|
|
13
|
+
with patch("termii_py.http.request_handler.RequestResponse.handle_response", return_value="handled") as mock_handle:
|
|
14
|
+
result = handler.fetch("/api/items", params={"page": 1})
|
|
15
|
+
|
|
16
|
+
assert result == "handled"
|
|
17
|
+
mock_get.assert_called_once_with(
|
|
18
|
+
"https://termii.example/api/items",
|
|
19
|
+
params={"page": 1, "api_key": "secret-key"},
|
|
20
|
+
)
|
|
21
|
+
mock_handle.assert_called_once_with(response)
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def test_post_adds_api_key():
|
|
25
|
+
handler = RequestHandler("secret-key", "https://termii.example")
|
|
26
|
+
response = SimpleNamespace(status_code=201, text="created")
|
|
27
|
+
|
|
28
|
+
with patch("termii_py.http.request_handler.requests.post", return_value=response) as mock_post:
|
|
29
|
+
with patch("termii_py.http.request_handler.RequestResponse.handle_response", return_value="handled") as mock_handle:
|
|
30
|
+
result = handler.post("/api/items", json={"name": "demo"})
|
|
31
|
+
|
|
32
|
+
assert result == "handled"
|
|
33
|
+
mock_post.assert_called_once_with(
|
|
34
|
+
"https://termii.example/api/items",
|
|
35
|
+
json={"name": "demo", "api_key": "secret-key"},
|
|
36
|
+
)
|
|
37
|
+
mock_handle.assert_called_once_with(response)
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def test_patch_adds_api_key():
|
|
41
|
+
handler = RequestHandler("secret-key", "https://termii.example")
|
|
42
|
+
response = SimpleNamespace(status_code=200, text="updated")
|
|
43
|
+
|
|
44
|
+
with patch("termii_py.http.request_handler.requests.patch", return_value=response) as mock_patch:
|
|
45
|
+
with patch("termii_py.http.request_handler.RequestResponse.handle_response", return_value="handled") as mock_handle:
|
|
46
|
+
result = handler.patch("/api/items/1", json={"name": "demo"})
|
|
47
|
+
|
|
48
|
+
assert result == "handled"
|
|
49
|
+
mock_patch.assert_called_once_with(
|
|
50
|
+
"https://termii.example/api/items/1",
|
|
51
|
+
json={"name": "demo", "api_key": "secret-key"},
|
|
52
|
+
)
|
|
53
|
+
mock_handle.assert_called_once_with(response)
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def test_delete_adds_api_key():
|
|
57
|
+
handler = RequestHandler("secret-key", "https://termii.example")
|
|
58
|
+
response = SimpleNamespace(status_code=200, text="deleted")
|
|
59
|
+
|
|
60
|
+
with patch("termii_py.http.request_handler.requests.delete", return_value=response) as mock_delete:
|
|
61
|
+
with patch("termii_py.http.request_handler.RequestResponse.handle_response", return_value="handled") as mock_handle:
|
|
62
|
+
result = handler.delete("/api/items/1", params={"force": True})
|
|
63
|
+
|
|
64
|
+
assert result == "handled"
|
|
65
|
+
mock_delete.assert_called_once_with(
|
|
66
|
+
"https://termii.example/api/items/1",
|
|
67
|
+
params={"force": True, "api_key": "secret-key"},
|
|
68
|
+
)
|
|
69
|
+
mock_handle.assert_called_once_with(response)
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def test_post_file_uses_file_upload_and_closes_file(tmp_path: Path):
|
|
73
|
+
handler = RequestHandler("secret-key", "https://termii.example")
|
|
74
|
+
file_path = tmp_path / "contacts.csv"
|
|
75
|
+
file_path.write_text("phone_number\n2348012345678\n", encoding="utf-8")
|
|
76
|
+
response = SimpleNamespace(status_code=200, text="uploaded")
|
|
77
|
+
|
|
78
|
+
with patch("termii_py.http.request_handler.requests.post", return_value=response) as mock_post:
|
|
79
|
+
with patch("termii_py.http.request_handler.RequestResponse.handle_response", return_value="handled") as mock_handle:
|
|
80
|
+
result = handler.post_file(
|
|
81
|
+
"/api/upload", str(file_path), {"phonebook_id": "abc123"})
|
|
82
|
+
|
|
83
|
+
assert result == "handled"
|
|
84
|
+
files = mock_post.call_args.kwargs["files"]
|
|
85
|
+
data = mock_post.call_args.kwargs["data"]
|
|
86
|
+
assert data == {"phonebook_id": "abc123", "api_key": "secret-key"}
|
|
87
|
+
assert files["file"].closed is True
|
|
88
|
+
mock_handle.assert_called_once_with(response)
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def test_request_response_marks_success_and_error():
|
|
92
|
+
ok_response = SimpleNamespace(
|
|
93
|
+
status_code=200, text="{""status"": ""success""}")
|
|
94
|
+
created_response = SimpleNamespace(
|
|
95
|
+
status_code=201, text="{""status"": ""created""}")
|
|
96
|
+
error_response = SimpleNamespace(status_code=400, text="bad request")
|
|
97
|
+
|
|
98
|
+
ok_result = RequestResponse.handle_response(ok_response)
|
|
99
|
+
created_result = RequestResponse.handle_response(created_response)
|
|
100
|
+
error_result = RequestResponse.handle_response(error_response)
|
|
101
|
+
|
|
102
|
+
assert ok_result.status_code == 200
|
|
103
|
+
assert ok_result.status == "ok"
|
|
104
|
+
assert ok_result.message == ok_response.text
|
|
105
|
+
assert created_result.status_code == 201
|
|
106
|
+
assert created_result.status == "ok"
|
|
107
|
+
assert created_result.message == created_response.text
|
|
108
|
+
assert error_result.status_code == 400
|
|
109
|
+
assert error_result.status == "error"
|
|
110
|
+
assert error_result.message == "bad request"
|
|
@@ -0,0 +1,515 @@
|
|
|
1
|
+
from unittest.mock import MagicMock
|
|
2
|
+
|
|
3
|
+
import pytest
|
|
4
|
+
|
|
5
|
+
from termii_py.services.campaign import CampaignService
|
|
6
|
+
from termii_py.services.contact import ContactService
|
|
7
|
+
from termii_py.services.message import MessageService
|
|
8
|
+
from termii_py.services.number import NumberService
|
|
9
|
+
from termii_py.services.phonebook import PhonebookService
|
|
10
|
+
from termii_py.services.sender_id import SenderIDService
|
|
11
|
+
from termii_py.services.template import TemplateService
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def make_http_mock():
|
|
15
|
+
return MagicMock()
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def test_sender_id_fetch_id_uses_optional_filters():
|
|
19
|
+
http = make_http_mock()
|
|
20
|
+
http.fetch.return_value = "ok"
|
|
21
|
+
service = SenderIDService(http)
|
|
22
|
+
|
|
23
|
+
result = service.fetch_id(name="MyBrand", status="approved")
|
|
24
|
+
|
|
25
|
+
assert result == "ok"
|
|
26
|
+
http.fetch.assert_called_once_with(
|
|
27
|
+
"/api/sender-id", params={"name": "MyBrand", "status": "approved"})
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def test_sender_id_request_id_builds_payload():
|
|
31
|
+
http = make_http_mock()
|
|
32
|
+
http.post.return_value = "ok"
|
|
33
|
+
service = SenderIDService(http)
|
|
34
|
+
|
|
35
|
+
result = service.request_id("MyBrand", "Transactional alerts", "Acme Ltd")
|
|
36
|
+
|
|
37
|
+
assert result == "ok"
|
|
38
|
+
http.post.assert_called_once_with(
|
|
39
|
+
"/api/sender-id/request",
|
|
40
|
+
json={"sender_id": "MyBrand",
|
|
41
|
+
"usecase": "Transactional alerts", "company": "Acme Ltd"},
|
|
42
|
+
)
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def test_message_send_message_allows_generic_and_voice():
|
|
46
|
+
http = make_http_mock()
|
|
47
|
+
http.post.return_value = "ok"
|
|
48
|
+
service = MessageService(http)
|
|
49
|
+
|
|
50
|
+
result = service.send_message(
|
|
51
|
+
sent_to="2348012345678",
|
|
52
|
+
sent_from="MyBrand",
|
|
53
|
+
message="Your order has been confirmed.",
|
|
54
|
+
channel="generic",
|
|
55
|
+
type="plain",
|
|
56
|
+
)
|
|
57
|
+
|
|
58
|
+
assert result == "ok"
|
|
59
|
+
http.post.assert_called_once_with(
|
|
60
|
+
"/api/sms/send",
|
|
61
|
+
json={
|
|
62
|
+
"to": "2348012345678",
|
|
63
|
+
"from": "MyBrand",
|
|
64
|
+
"sms": "Your order has been confirmed.",
|
|
65
|
+
"channel": "generic",
|
|
66
|
+
"type": "plain",
|
|
67
|
+
},
|
|
68
|
+
)
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def test_message_send_message_allows_voice_when_type_matches():
|
|
72
|
+
http = make_http_mock()
|
|
73
|
+
http.post.return_value = "ok"
|
|
74
|
+
service = MessageService(http)
|
|
75
|
+
|
|
76
|
+
result = service.send_message(
|
|
77
|
+
sent_to="2348012345678",
|
|
78
|
+
sent_from="MyBrand",
|
|
79
|
+
message="Press 1 to confirm.",
|
|
80
|
+
channel="voice",
|
|
81
|
+
type="voice",
|
|
82
|
+
)
|
|
83
|
+
|
|
84
|
+
assert result == "ok"
|
|
85
|
+
http.post.assert_called_once()
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
@pytest.mark.parametrize(
|
|
89
|
+
"channel, message_type, error_message",
|
|
90
|
+
[
|
|
91
|
+
("whatsapp", "plain", "For WhatsApp messages"),
|
|
92
|
+
("voice", "plain", "For voice channel"),
|
|
93
|
+
("invalid", "plain", "must be either 'generic' or 'dnd' or voice"),
|
|
94
|
+
],
|
|
95
|
+
)
|
|
96
|
+
def test_message_send_message_validates_channel(channel, message_type, error_message):
|
|
97
|
+
http = make_http_mock()
|
|
98
|
+
service = MessageService(http)
|
|
99
|
+
|
|
100
|
+
with pytest.raises(ValueError, match=error_message):
|
|
101
|
+
service.send_message(
|
|
102
|
+
sent_to="2348012345678",
|
|
103
|
+
sent_from="MyBrand",
|
|
104
|
+
message="Hello",
|
|
105
|
+
channel=channel,
|
|
106
|
+
type=message_type,
|
|
107
|
+
)
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def test_message_send_whatsapp_message_builds_payload():
|
|
111
|
+
http = make_http_mock()
|
|
112
|
+
http.post.return_value = "ok"
|
|
113
|
+
service = MessageService(http)
|
|
114
|
+
|
|
115
|
+
result = service.send_whatsapp_message(
|
|
116
|
+
sent_to="2348012345678",
|
|
117
|
+
sent_from="MyBrand",
|
|
118
|
+
message="Hello!",
|
|
119
|
+
)
|
|
120
|
+
|
|
121
|
+
assert result == "ok"
|
|
122
|
+
http.post.assert_called_once_with(
|
|
123
|
+
"/api/sms/send",
|
|
124
|
+
json={
|
|
125
|
+
"to": "2348012345678",
|
|
126
|
+
"from": "MyBrand",
|
|
127
|
+
"sms": "Hello!",
|
|
128
|
+
"channel": "whatsapp",
|
|
129
|
+
"type": "plain",
|
|
130
|
+
"media": {"url": None, "caption": None},
|
|
131
|
+
},
|
|
132
|
+
)
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def test_message_send_bulk_message_builds_payload():
|
|
136
|
+
http = make_http_mock()
|
|
137
|
+
http.post.return_value = "ok"
|
|
138
|
+
service = MessageService(http)
|
|
139
|
+
|
|
140
|
+
result = service.send_bulk_message(
|
|
141
|
+
sent_to=["2348012345678", "2348098765432"],
|
|
142
|
+
sent_from="MyBrand",
|
|
143
|
+
message="Promo",
|
|
144
|
+
channel="dnd",
|
|
145
|
+
type="plain",
|
|
146
|
+
)
|
|
147
|
+
|
|
148
|
+
assert result == "ok"
|
|
149
|
+
http.post.assert_called_once_with(
|
|
150
|
+
"/api/sms/send/bulk",
|
|
151
|
+
json={
|
|
152
|
+
"to": ["2348012345678", "2348098765432"],
|
|
153
|
+
"from": "MyBrand",
|
|
154
|
+
"sms": "Promo",
|
|
155
|
+
"channel": "dnd",
|
|
156
|
+
"type": "plain",
|
|
157
|
+
},
|
|
158
|
+
)
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
@pytest.mark.parametrize(
|
|
162
|
+
"channel, message_type, error_message",
|
|
163
|
+
[
|
|
164
|
+
("whatsapp", "plain", "For WhatsApp messages"),
|
|
165
|
+
("voice", "plain", "Voice messages are not supported in bulk messaging."),
|
|
166
|
+
("generic", "voice", "Voice messages are not supported in bulk messaging."),
|
|
167
|
+
("invalid", "plain", "must be either 'generic' or 'dnd' or voice"),
|
|
168
|
+
],
|
|
169
|
+
)
|
|
170
|
+
def test_message_send_bulk_message_validates_channel(channel, message_type, error_message):
|
|
171
|
+
http = make_http_mock()
|
|
172
|
+
service = MessageService(http)
|
|
173
|
+
|
|
174
|
+
with pytest.raises(ValueError, match=error_message):
|
|
175
|
+
service.send_bulk_message(
|
|
176
|
+
sent_to=["2348012345678"],
|
|
177
|
+
sent_from="MyBrand",
|
|
178
|
+
message="Promo",
|
|
179
|
+
channel=channel,
|
|
180
|
+
type=message_type,
|
|
181
|
+
)
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
def test_number_send_message_builds_payload():
|
|
185
|
+
http = make_http_mock()
|
|
186
|
+
http.post.return_value = "ok"
|
|
187
|
+
service = NumberService(http)
|
|
188
|
+
|
|
189
|
+
result = service.send_message(
|
|
190
|
+
sent_to="2348012345678", message="Your code is 123456")
|
|
191
|
+
|
|
192
|
+
assert result == "ok"
|
|
193
|
+
http.post.assert_called_once_with(
|
|
194
|
+
"/api/sms/number/send",
|
|
195
|
+
json={"to": "2348012345678", "sms": "Your code is 123456"},
|
|
196
|
+
)
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
def test_template_send_message_builds_text_payload():
|
|
200
|
+
http = make_http_mock()
|
|
201
|
+
http.post.return_value = "ok"
|
|
202
|
+
service = TemplateService(http)
|
|
203
|
+
|
|
204
|
+
result = service.send_message(
|
|
205
|
+
sent_to="2348012345678",
|
|
206
|
+
device_id="device-1",
|
|
207
|
+
template_id="template-1",
|
|
208
|
+
data={"name": "Ada"},
|
|
209
|
+
)
|
|
210
|
+
|
|
211
|
+
assert result == "ok"
|
|
212
|
+
http.post.assert_called_once_with(
|
|
213
|
+
"/api/send/template",
|
|
214
|
+
json={
|
|
215
|
+
"phone_number": "2348012345678",
|
|
216
|
+
"device_id": "device-1",
|
|
217
|
+
"template_id": "template-1",
|
|
218
|
+
"data": {"name": "Ada"},
|
|
219
|
+
},
|
|
220
|
+
)
|
|
221
|
+
|
|
222
|
+
|
|
223
|
+
def test_template_send_message_builds_media_payload():
|
|
224
|
+
http = make_http_mock()
|
|
225
|
+
http.post.return_value = "ok"
|
|
226
|
+
service = TemplateService(http)
|
|
227
|
+
|
|
228
|
+
result = service.send_message(
|
|
229
|
+
sent_to="2348012345678",
|
|
230
|
+
device_id="device-1",
|
|
231
|
+
template_id="template-1",
|
|
232
|
+
data={"name": "Ada"},
|
|
233
|
+
caption="Receipt",
|
|
234
|
+
url="https://example.com/receipt.pdf",
|
|
235
|
+
)
|
|
236
|
+
|
|
237
|
+
assert result == "ok"
|
|
238
|
+
http.post.assert_called_once_with(
|
|
239
|
+
"/api/send/template/media",
|
|
240
|
+
json={
|
|
241
|
+
"phone_number": "2348012345678",
|
|
242
|
+
"device_id": "device-1",
|
|
243
|
+
"template_id": "template-1",
|
|
244
|
+
"data": {"name": "Ada"},
|
|
245
|
+
"media": {"caption": "Receipt", "url": "https://example.com/receipt.pdf"},
|
|
246
|
+
},
|
|
247
|
+
)
|
|
248
|
+
|
|
249
|
+
|
|
250
|
+
@pytest.mark.parametrize(
|
|
251
|
+
"caption, url, error_message",
|
|
252
|
+
[
|
|
253
|
+
("Receipt", None, "If caption is provided, url must also be provided"),
|
|
254
|
+
(None, "https://example.com/receipt.pdf",
|
|
255
|
+
"If url is provided, caption must also be provided"),
|
|
256
|
+
(None, None, None),
|
|
257
|
+
],
|
|
258
|
+
)
|
|
259
|
+
def test_template_send_message_validates_media_arguments(caption, url, error_message):
|
|
260
|
+
http = make_http_mock()
|
|
261
|
+
service = TemplateService(http)
|
|
262
|
+
|
|
263
|
+
if error_message:
|
|
264
|
+
with pytest.raises(ValueError, match=error_message):
|
|
265
|
+
service.send_message(
|
|
266
|
+
sent_to="2348012345678",
|
|
267
|
+
device_id="device-1",
|
|
268
|
+
template_id="template-1",
|
|
269
|
+
data={"name": "Ada"},
|
|
270
|
+
caption=caption,
|
|
271
|
+
url=url,
|
|
272
|
+
)
|
|
273
|
+
else:
|
|
274
|
+
service.send_message(
|
|
275
|
+
sent_to="2348012345678",
|
|
276
|
+
device_id="device-1",
|
|
277
|
+
template_id="template-1",
|
|
278
|
+
data={"name": "Ada"},
|
|
279
|
+
)
|
|
280
|
+
|
|
281
|
+
|
|
282
|
+
def test_template_send_message_requires_dict_data():
|
|
283
|
+
http = make_http_mock()
|
|
284
|
+
service = TemplateService(http)
|
|
285
|
+
|
|
286
|
+
with pytest.raises(ValueError, match="The 'data' parameter must be a dictionary"):
|
|
287
|
+
service.send_message(
|
|
288
|
+
sent_to="2348012345678",
|
|
289
|
+
device_id="device-1",
|
|
290
|
+
template_id="template-1",
|
|
291
|
+
data=["not", "a", "dict"],
|
|
292
|
+
)
|
|
293
|
+
|
|
294
|
+
|
|
295
|
+
def test_phonebook_methods_delegate_to_http():
|
|
296
|
+
http = make_http_mock()
|
|
297
|
+
http.fetch.return_value = "fetched"
|
|
298
|
+
http.post.return_value = "created"
|
|
299
|
+
http.patch.return_value = "updated"
|
|
300
|
+
http.delete.return_value = "deleted"
|
|
301
|
+
service = PhonebookService(http)
|
|
302
|
+
|
|
303
|
+
assert service.fetch_phonebooks() == "fetched"
|
|
304
|
+
assert service.create_phonebooks(
|
|
305
|
+
"Newsletter", "Weekly updates") == "created"
|
|
306
|
+
assert service.update_phonebook(
|
|
307
|
+
"pb-1", "VIP", "Top customers") == "updated"
|
|
308
|
+
assert service.delete_phonebook("pb-1") == "deleted"
|
|
309
|
+
|
|
310
|
+
http.fetch.assert_called_once_with("/api/phonebooks")
|
|
311
|
+
http.post.assert_called_once_with(
|
|
312
|
+
"/api/phonebooks",
|
|
313
|
+
json={"phonebook_name": "Newsletter", "description": "Weekly updates"},
|
|
314
|
+
)
|
|
315
|
+
http.patch.assert_called_once_with(
|
|
316
|
+
"/api/phonebooks/pb-1",
|
|
317
|
+
json={"phonebook_name": "VIP", "description": "Top customers"},
|
|
318
|
+
)
|
|
319
|
+
http.delete.assert_called_once_with("/api/phonebooks/pb-1")
|
|
320
|
+
|
|
321
|
+
|
|
322
|
+
def test_phonebook_update_and_delete_require_ids():
|
|
323
|
+
http = make_http_mock()
|
|
324
|
+
service = PhonebookService(http)
|
|
325
|
+
|
|
326
|
+
with pytest.raises(ValueError, match="phonebook_id is required to update a phonebook"):
|
|
327
|
+
service.update_phonebook("", "VIP", "Top customers")
|
|
328
|
+
|
|
329
|
+
with pytest.raises(ValueError, match="phonebook_id is required to delete a phonebook"):
|
|
330
|
+
service.delete_phonebook(None)
|
|
331
|
+
|
|
332
|
+
|
|
333
|
+
def test_contact_methods_delegate_to_http():
|
|
334
|
+
http = make_http_mock()
|
|
335
|
+
http.fetch.return_value = "contacts"
|
|
336
|
+
http.post.return_value = "created"
|
|
337
|
+
http.post_file.return_value = "uploaded"
|
|
338
|
+
http.delete.return_value = "deleted"
|
|
339
|
+
service = ContactService(http)
|
|
340
|
+
|
|
341
|
+
assert service.fetch_contacts("pb-1") == "contacts"
|
|
342
|
+
assert service.create_contact(
|
|
343
|
+
"pb-1",
|
|
344
|
+
phone_number="8012345678",
|
|
345
|
+
country_code="234",
|
|
346
|
+
email_address="ada@example.com",
|
|
347
|
+
first_name="Ada",
|
|
348
|
+
last_name="Obi",
|
|
349
|
+
company="Acme Ltd",
|
|
350
|
+
) == "created"
|
|
351
|
+
assert service.create_multiple_contacts(
|
|
352
|
+
"pb-1", "234", "contacts.csv") == "uploaded"
|
|
353
|
+
assert service.delete_contact("pb-1") == "deleted"
|
|
354
|
+
|
|
355
|
+
http.fetch.assert_called_once_with("/api/phonebooks/pb-1/contacts")
|
|
356
|
+
http.post.assert_called_once_with(
|
|
357
|
+
"/api/phonebooks/pb-1/contacts",
|
|
358
|
+
json={
|
|
359
|
+
"phone_number": "8012345678",
|
|
360
|
+
"country_code": "234",
|
|
361
|
+
"email_address": "ada@example.com",
|
|
362
|
+
"first_name": "Ada",
|
|
363
|
+
"last_name": "Obi",
|
|
364
|
+
"company": "Acme Ltd",
|
|
365
|
+
},
|
|
366
|
+
)
|
|
367
|
+
http.post_file.assert_called_once_with(
|
|
368
|
+
"/api/phonebooks/contacts/upload",
|
|
369
|
+
data={"phonebook_id": "pb-1", "country_code": "234"},
|
|
370
|
+
file_path="contacts.csv",
|
|
371
|
+
)
|
|
372
|
+
http.delete.assert_called_once_with("/api/phonebooks/pb-1/contacts")
|
|
373
|
+
|
|
374
|
+
|
|
375
|
+
@pytest.mark.parametrize(
|
|
376
|
+
"method_name, args, error_message",
|
|
377
|
+
[
|
|
378
|
+
("fetch_contacts", {"phonebook_id": ""}, "phonebook_id is required"),
|
|
379
|
+
("create_contact", {"phonebook_id": "", "phone_number": "8012345678",
|
|
380
|
+
"country_code": "234"}, "phonebook_id is required"),
|
|
381
|
+
("create_contact", {"phonebook_id": "pb-1", "phone_number": "8012345678",
|
|
382
|
+
"country_code": "+234"}, "country code should not start"),
|
|
383
|
+
("create_multiple_contacts", {"phonebook_id": "", "country_code": "234",
|
|
384
|
+
"file_path": "contacts.csv"}, "phonebook_id is required"),
|
|
385
|
+
("create_multiple_contacts", {"phonebook_id": "pb-1", "country_code": "+234",
|
|
386
|
+
"file_path": "contacts.csv"}, "country code should not start"),
|
|
387
|
+
("delete_contact", {"phonebook_id": ""},
|
|
388
|
+
"phonebook_id is required to delete contacts"),
|
|
389
|
+
],
|
|
390
|
+
)
|
|
391
|
+
def test_contact_methods_validate_inputs(method_name, args, error_message):
|
|
392
|
+
http = make_http_mock()
|
|
393
|
+
service = ContactService(http)
|
|
394
|
+
|
|
395
|
+
with pytest.raises(ValueError, match=error_message):
|
|
396
|
+
getattr(service, method_name)(**args)
|
|
397
|
+
|
|
398
|
+
|
|
399
|
+
def test_campaign_send_campaign_builds_payload():
|
|
400
|
+
http = make_http_mock()
|
|
401
|
+
http.post.return_value = "ok"
|
|
402
|
+
service = CampaignService(http)
|
|
403
|
+
|
|
404
|
+
result = service.send_campaign(
|
|
405
|
+
country_code="234",
|
|
406
|
+
sender_id="MyBrand",
|
|
407
|
+
message="Big sale",
|
|
408
|
+
message_type="plain",
|
|
409
|
+
phonebook_id="pb-1",
|
|
410
|
+
enable_link_tracking=False,
|
|
411
|
+
campaign_type="promotional",
|
|
412
|
+
schedule_sms_status="regular",
|
|
413
|
+
channel="dnd",
|
|
414
|
+
)
|
|
415
|
+
|
|
416
|
+
assert result == "ok"
|
|
417
|
+
http.post.assert_called_once_with(
|
|
418
|
+
"/api/sms/campaigns/send",
|
|
419
|
+
{
|
|
420
|
+
"country_code": "234",
|
|
421
|
+
"sender_id": "MyBrand",
|
|
422
|
+
"message": "Big sale",
|
|
423
|
+
"message_type": "plain",
|
|
424
|
+
"phonebook_id": "pb-1",
|
|
425
|
+
"enable_link_tracking": False,
|
|
426
|
+
"campaign_type": "promotional",
|
|
427
|
+
"schedule_sms_status": "regular",
|
|
428
|
+
"schedule_time": None,
|
|
429
|
+
"channel": "dnd",
|
|
430
|
+
"remove_duplicate": "yes",
|
|
431
|
+
"delimiter": ",",
|
|
432
|
+
},
|
|
433
|
+
)
|
|
434
|
+
|
|
435
|
+
|
|
436
|
+
def test_campaign_send_campaign_requires_schedule_time_when_scheduled():
|
|
437
|
+
http = make_http_mock()
|
|
438
|
+
service = CampaignService(http)
|
|
439
|
+
|
|
440
|
+
with pytest.raises(ValueError, match="schedule time is required"):
|
|
441
|
+
service.send_campaign(
|
|
442
|
+
country_code="234",
|
|
443
|
+
sender_id="MyBrand",
|
|
444
|
+
message="Big sale",
|
|
445
|
+
message_type="plain",
|
|
446
|
+
phonebook_id="pb-1",
|
|
447
|
+
enable_link_tracking=False,
|
|
448
|
+
campaign_type="promotional",
|
|
449
|
+
schedule_sms_status="scheduled",
|
|
450
|
+
channel="dnd",
|
|
451
|
+
)
|
|
452
|
+
|
|
453
|
+
|
|
454
|
+
@pytest.mark.parametrize(
|
|
455
|
+
"kwargs, error_message",
|
|
456
|
+
[
|
|
457
|
+
({"country_code": "+234"}, "country code should not start"),
|
|
458
|
+
({"sender_id": "AB"}, "sender id should be between 3 and 11 characters"),
|
|
459
|
+
({"message_type": "emoji"},
|
|
460
|
+
"message type should be either 'plain' or 'unicode'"),
|
|
461
|
+
({"channel": "voice"}, "channel should be either 'dnd' or 'generic'"),
|
|
462
|
+
({"schedule_sms_status": "later"},
|
|
463
|
+
"schedule sms status should be either 'scheduled' or 'regular'"),
|
|
464
|
+
],
|
|
465
|
+
)
|
|
466
|
+
def test_campaign_send_campaign_validates_inputs(kwargs, error_message):
|
|
467
|
+
http = make_http_mock()
|
|
468
|
+
service = CampaignService(http)
|
|
469
|
+
|
|
470
|
+
base_kwargs = {
|
|
471
|
+
"country_code": "234",
|
|
472
|
+
"sender_id": "MyBrand",
|
|
473
|
+
"message": "Big sale",
|
|
474
|
+
"message_type": "plain",
|
|
475
|
+
"phonebook_id": "pb-1",
|
|
476
|
+
"enable_link_tracking": False,
|
|
477
|
+
"campaign_type": "promotional",
|
|
478
|
+
"schedule_sms_status": "regular",
|
|
479
|
+
"channel": "dnd",
|
|
480
|
+
}
|
|
481
|
+
base_kwargs.update(kwargs)
|
|
482
|
+
|
|
483
|
+
with pytest.raises(ValueError, match=error_message):
|
|
484
|
+
service.send_campaign(**base_kwargs)
|
|
485
|
+
|
|
486
|
+
|
|
487
|
+
def test_campaign_fetch_and_retry_methods_delegate_to_http():
|
|
488
|
+
http = make_http_mock()
|
|
489
|
+
http.fetch.return_value = "campaigns"
|
|
490
|
+
http.patch.return_value = "retried"
|
|
491
|
+
service = CampaignService(http)
|
|
492
|
+
|
|
493
|
+
assert service.fetch_campaigns() == "campaigns"
|
|
494
|
+
assert service.fetch_campaign_history("camp-1") == "campaigns"
|
|
495
|
+
assert service.retry_campaign("camp-1") == "retried"
|
|
496
|
+
|
|
497
|
+
http.fetch.assert_any_call("/api/sms/campaigns")
|
|
498
|
+
http.fetch.assert_any_call("/api/sms/campaigns/camp-1")
|
|
499
|
+
http.patch.assert_called_once_with("/api/sms/campaigns/camp-1", json={})
|
|
500
|
+
|
|
501
|
+
|
|
502
|
+
@pytest.mark.parametrize(
|
|
503
|
+
"method_name, kwargs, error_message",
|
|
504
|
+
[
|
|
505
|
+
("fetch_campaign_history", {
|
|
506
|
+
"campaign_id": ""}, "campaign id is required"),
|
|
507
|
+
("retry_campaign", {"campaign_id": None}, "campaign id is required"),
|
|
508
|
+
],
|
|
509
|
+
)
|
|
510
|
+
def test_campaign_campaign_id_is_required(method_name, kwargs, error_message):
|
|
511
|
+
http = make_http_mock()
|
|
512
|
+
service = CampaignService(http)
|
|
513
|
+
|
|
514
|
+
with pytest.raises(ValueError, match=error_message):
|
|
515
|
+
getattr(service, method_name)(**kwargs)
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import pytest
|
|
2
|
+
|
|
3
|
+
from termii_py.value_object import PhoneNumber
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
@pytest.mark.parametrize(
|
|
7
|
+
"phone_number",
|
|
8
|
+
[
|
|
9
|
+
"2348012345678",
|
|
10
|
+
"2349012345678",
|
|
11
|
+
],
|
|
12
|
+
)
|
|
13
|
+
def test_phone_number_accepts_valid_msisdn(phone_number):
|
|
14
|
+
value = PhoneNumber(phone_number)
|
|
15
|
+
|
|
16
|
+
assert value.phone_number == phone_number
|
|
17
|
+
assert PhoneNumber.is_valid_phone_number(phone_number) is True
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
@pytest.mark.parametrize(
|
|
21
|
+
"phone_number",
|
|
22
|
+
[
|
|
23
|
+
"08012345678",
|
|
24
|
+
"234801234567",
|
|
25
|
+
"23480123456789",
|
|
26
|
+
"23480abcdef12",
|
|
27
|
+
"",
|
|
28
|
+
None,
|
|
29
|
+
],
|
|
30
|
+
)
|
|
31
|
+
def test_phone_number_rejects_invalid_values(phone_number):
|
|
32
|
+
with pytest.raises(ValueError, match="Invalid phone number"):
|
|
33
|
+
PhoneNumber(phone_number)
|
|
34
|
+
|
|
35
|
+
assert PhoneNumber.is_valid_phone_number(phone_number) is False
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|