amochka 0.4.0__tar.gz → 0.4.3__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.
- {amochka-0.4.0 → amochka-0.4.3}/PKG-INFO +1 -1
- {amochka-0.4.0 → amochka-0.4.3}/amochka/__init__.py +1 -1
- {amochka-0.4.0 → amochka-0.4.3}/amochka/client.py +28 -4
- {amochka-0.4.0 → amochka-0.4.3}/amochka/etl.py +2 -0
- {amochka-0.4.0 → amochka-0.4.3}/amochka.egg-info/PKG-INFO +1 -1
- {amochka-0.4.0 → amochka-0.4.3}/amochka.egg-info/SOURCES.txt +7 -1
- {amochka-0.4.0 → amochka-0.4.3}/pyproject.toml +1 -1
- amochka-0.4.3/tests/test_cache.py +87 -0
- amochka-0.4.3/tests/test_etl.py +57 -0
- amochka-0.4.3/tests/test_http.py +183 -0
- amochka-0.4.3/tests/test_notes_events.py +46 -0
- amochka-0.4.3/tests/test_security.py +211 -0
- amochka-0.4.3/tests/test_utils.py +91 -0
- {amochka-0.4.0 → amochka-0.4.3}/README.md +0 -0
- {amochka-0.4.0 → amochka-0.4.3}/amochka/errors.py +0 -0
- {amochka-0.4.0 → amochka-0.4.3}/amochka.egg-info/dependency_links.txt +0 -0
- {amochka-0.4.0 → amochka-0.4.3}/amochka.egg-info/requires.txt +0 -0
- {amochka-0.4.0 → amochka-0.4.3}/amochka.egg-info/top_level.txt +0 -0
- {amochka-0.4.0 → amochka-0.4.3}/etl/__init__.py +0 -0
- {amochka-0.4.0 → amochka-0.4.3}/etl/config.py +0 -0
- {amochka-0.4.0 → amochka-0.4.3}/etl/extractors.py +0 -0
- {amochka-0.4.0 → amochka-0.4.3}/etl/loaders.py +0 -0
- {amochka-0.4.0 → amochka-0.4.3}/etl/migrations/001_create_tables.sql +0 -0
- {amochka-0.4.0 → amochka-0.4.3}/etl/run_etl.py +0 -0
- {amochka-0.4.0 → amochka-0.4.3}/etl/transformers.py +0 -0
- {amochka-0.4.0 → amochka-0.4.3}/setup.cfg +0 -0
- {amochka-0.4.0 → amochka-0.4.3}/tests/test_client.py +0 -0
|
@@ -477,11 +477,15 @@ class AmoCRMClient:
|
|
|
477
477
|
'redirect_uri': os.environ.get('AMOCRM_REDIRECT_URI'),
|
|
478
478
|
}
|
|
479
479
|
# 2. Загружаем из файла или строки
|
|
480
|
-
elif os.path.exists(self.token_file):
|
|
480
|
+
elif self.token_file and os.path.exists(self.token_file):
|
|
481
481
|
with open(self.token_file, 'r') as f:
|
|
482
482
|
data = json.load(f)
|
|
483
483
|
self.logger.debug(f"Token loaded from file: {self.token_file}")
|
|
484
484
|
else:
|
|
485
|
+
if not self.token_file:
|
|
486
|
+
raise AuthenticationError(
|
|
487
|
+
"Токен не найден: ни в environment variables, ни в файле, ни в переданной строке."
|
|
488
|
+
)
|
|
485
489
|
try:
|
|
486
490
|
data = json.loads(self.token_file)
|
|
487
491
|
self.logger.debug("Token parsed from provided string.")
|
|
@@ -583,7 +587,11 @@ class AmoCRMClient:
|
|
|
583
587
|
if response.status_code in (200, 204):
|
|
584
588
|
if response.status_code == 204:
|
|
585
589
|
return None
|
|
586
|
-
|
|
590
|
+
try:
|
|
591
|
+
return response.json()
|
|
592
|
+
except ValueError:
|
|
593
|
+
preview = response.text[:200] if response.text else ""
|
|
594
|
+
raise APIError(response.status_code, f"Invalid JSON response: {preview}")
|
|
587
595
|
|
|
588
596
|
# Retryable ошибки (429, 5xx)
|
|
589
597
|
if response.status_code in retryable_status_codes:
|
|
@@ -675,7 +683,10 @@ class AmoCRMClient:
|
|
|
675
683
|
self.logger.error(f"Не удалось обновить токен: {resp.status_code}")
|
|
676
684
|
raise AuthenticationError(f"Не удалось обновить токен: {resp.status_code}")
|
|
677
685
|
|
|
678
|
-
|
|
686
|
+
try:
|
|
687
|
+
data = resp.json() or {}
|
|
688
|
+
except ValueError as exc:
|
|
689
|
+
raise AuthenticationError("Ответ refresh_token не является валидным JSON") from exc
|
|
679
690
|
access_token = data.get("access_token")
|
|
680
691
|
refresh_token = data.get("refresh_token", self.refresh_token)
|
|
681
692
|
expires_in = data.get("expires_in")
|
|
@@ -921,7 +932,10 @@ class AmoCRMClient:
|
|
|
921
932
|
return int(value)
|
|
922
933
|
if isinstance(value, str):
|
|
923
934
|
try:
|
|
924
|
-
|
|
935
|
+
value_str = value.strip()
|
|
936
|
+
if value_str.endswith("Z"):
|
|
937
|
+
value_str = value_str[:-1] + "+00:00"
|
|
938
|
+
return int(datetime.fromisoformat(value_str).timestamp())
|
|
925
939
|
except ValueError as exc:
|
|
926
940
|
raise ValueError(f"Не удалось преобразовать '{value}' в timestamp") from exc
|
|
927
941
|
raise TypeError(f"Неподдерживаемый тип для timestamp: {type(value)}")
|
|
@@ -1591,6 +1605,9 @@ class AmoCRMClient:
|
|
|
1591
1605
|
notes = []
|
|
1592
1606
|
while True:
|
|
1593
1607
|
response = self._make_request("GET", endpoint, params=params)
|
|
1608
|
+
if not response:
|
|
1609
|
+
self.logger.warning(f"Empty response for notes {entity} {entity_id}, stopping pagination")
|
|
1610
|
+
break
|
|
1594
1611
|
if response and "_embedded" in response and "notes" in response["_embedded"]:
|
|
1595
1612
|
notes.extend(response["_embedded"]["notes"])
|
|
1596
1613
|
if not get_all:
|
|
@@ -1625,6 +1642,8 @@ class AmoCRMClient:
|
|
|
1625
1642
|
endpoint = f"/api/v4/{plural}/{entity_id}/notes/{note_id}"
|
|
1626
1643
|
self.logger.debug(f"Fetching note {note_id} for {entity} {entity_id}")
|
|
1627
1644
|
note_data = self._make_request("GET", endpoint)
|
|
1645
|
+
if not note_data or not isinstance(note_data, dict):
|
|
1646
|
+
raise APIError(0, f"Invalid response for note {note_id} {entity} {entity_id}.")
|
|
1628
1647
|
self.logger.debug(f"Note {note_id} for {entity} {entity_id} fetched successfully.")
|
|
1629
1648
|
return note_data
|
|
1630
1649
|
|
|
@@ -1670,6 +1689,9 @@ class AmoCRMClient:
|
|
|
1670
1689
|
events = []
|
|
1671
1690
|
while True:
|
|
1672
1691
|
response = self._make_request("GET", "/api/v4/events", params=params)
|
|
1692
|
+
if not response:
|
|
1693
|
+
self.logger.warning(f"Empty response for events {entity} {entity_id}, stopping pagination")
|
|
1694
|
+
break
|
|
1673
1695
|
if response and "_embedded" in response and "events" in response["_embedded"]:
|
|
1674
1696
|
events.extend(response["_embedded"]["events"])
|
|
1675
1697
|
# Если не нужно получать все страницы, выходим
|
|
@@ -1736,6 +1758,8 @@ class AmoCRMClient:
|
|
|
1736
1758
|
endpoint = f"/api/v4/events/{event_id}"
|
|
1737
1759
|
self.logger.debug(f"Fetching event with ID {event_id}")
|
|
1738
1760
|
event_data = self._make_request("GET", endpoint)
|
|
1761
|
+
if not event_data or not isinstance(event_data, dict):
|
|
1762
|
+
raise APIError(0, f"Invalid response for event {event_id}.")
|
|
1739
1763
|
self.logger.debug(f"Event {event_id} details fetched successfully.")
|
|
1740
1764
|
return event_data
|
|
1741
1765
|
|
|
@@ -123,6 +123,8 @@ def export_contacts_to_ndjson(
|
|
|
123
123
|
params["limit"] = limit
|
|
124
124
|
while True:
|
|
125
125
|
response = client._make_request("GET", "/api/v4/contacts", params=params)
|
|
126
|
+
if not response:
|
|
127
|
+
break
|
|
126
128
|
embedded = (response or {}).get("_embedded", {})
|
|
127
129
|
contacts = embedded.get("contacts") or []
|
|
128
130
|
if not contacts:
|
|
@@ -16,4 +16,10 @@ etl/loaders.py
|
|
|
16
16
|
etl/run_etl.py
|
|
17
17
|
etl/transformers.py
|
|
18
18
|
etl/migrations/001_create_tables.sql
|
|
19
|
-
tests/
|
|
19
|
+
tests/test_cache.py
|
|
20
|
+
tests/test_client.py
|
|
21
|
+
tests/test_etl.py
|
|
22
|
+
tests/test_http.py
|
|
23
|
+
tests/test_notes_events.py
|
|
24
|
+
tests/test_security.py
|
|
25
|
+
tests/test_utils.py
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
import json
|
|
2
|
+
import os
|
|
3
|
+
import sys
|
|
4
|
+
import time
|
|
5
|
+
|
|
6
|
+
sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), "..", "..")))
|
|
7
|
+
|
|
8
|
+
import amochka.client as client_module
|
|
9
|
+
from amochka import AmoCRMClient, CacheConfig
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def make_client(cache_config):
|
|
13
|
+
token_data = {"access_token": "token", "expires_at": str(time.time() + 3600)}
|
|
14
|
+
return AmoCRMClient(
|
|
15
|
+
base_url="https://example.amocrm.ru",
|
|
16
|
+
token_file=json.dumps(token_data),
|
|
17
|
+
cache_config=cache_config,
|
|
18
|
+
disable_logging=True,
|
|
19
|
+
)
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def make_file_client(tmp_path, lifetime_hours=1):
|
|
23
|
+
token_path = tmp_path / "token.json"
|
|
24
|
+
token_path.write_text(json.dumps({"access_token": "token", "expires_at": str(time.time() + 3600)}))
|
|
25
|
+
cache_dir = tmp_path / "cache"
|
|
26
|
+
cache_config = CacheConfig.file_cache(base_dir=str(cache_dir), lifetime_hours=lifetime_hours)
|
|
27
|
+
return AmoCRMClient(
|
|
28
|
+
base_url="https://example.amocrm.ru",
|
|
29
|
+
token_file=str(token_path),
|
|
30
|
+
cache_config=cache_config,
|
|
31
|
+
disable_logging=True,
|
|
32
|
+
)
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def test_memory_cache_ttl(monkeypatch):
|
|
36
|
+
client = make_client(CacheConfig.memory_only())
|
|
37
|
+
client._memory_cache_ttl = 10
|
|
38
|
+
|
|
39
|
+
now = {"value": 100.0}
|
|
40
|
+
|
|
41
|
+
def fake_time():
|
|
42
|
+
return now["value"]
|
|
43
|
+
|
|
44
|
+
monkeypatch.setattr(client_module.time, "time", fake_time)
|
|
45
|
+
|
|
46
|
+
calls = {"count": 0}
|
|
47
|
+
|
|
48
|
+
def fetch():
|
|
49
|
+
calls["count"] += 1
|
|
50
|
+
return [calls["count"]]
|
|
51
|
+
|
|
52
|
+
assert client._get_cached_resource("users", fetch) == [1]
|
|
53
|
+
now["value"] = 105.0
|
|
54
|
+
assert client._get_cached_resource("users", fetch) == [1]
|
|
55
|
+
assert calls["count"] == 1
|
|
56
|
+
now["value"] = 120.0
|
|
57
|
+
assert client._get_cached_resource("users", fetch) == [2]
|
|
58
|
+
assert calls["count"] == 2
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def test_file_cache_save_and_load(tmp_path):
|
|
62
|
+
client = make_file_client(tmp_path, lifetime_hours=1)
|
|
63
|
+
payload = [{"id": 1}]
|
|
64
|
+
client._save_cache("users", payload)
|
|
65
|
+
loaded = client._load_cache("users")
|
|
66
|
+
assert loaded == payload
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def test_file_cache_expired_returns_none(tmp_path):
|
|
70
|
+
client = make_file_client(tmp_path, lifetime_hours=1)
|
|
71
|
+
payload = [{"id": 1}]
|
|
72
|
+
client._save_cache("users", payload)
|
|
73
|
+
cache_path = client._get_cache_file_path("users")
|
|
74
|
+
with open(cache_path, "r", encoding="utf-8") as handle:
|
|
75
|
+
cache_data = json.loads(handle.read())
|
|
76
|
+
cache_data["last_updated"] = time.time() - 7200
|
|
77
|
+
with open(cache_path, "w", encoding="utf-8") as handle:
|
|
78
|
+
json.dump(cache_data, handle)
|
|
79
|
+
assert client._load_cache("users") is None
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def test_get_lifetime_default_and_none():
|
|
83
|
+
config = CacheConfig(lifetime_hours={"users": 5})
|
|
84
|
+
assert config.get_lifetime("users") == 5
|
|
85
|
+
assert config.get_lifetime("unknown") == 24
|
|
86
|
+
config = CacheConfig(lifetime_hours=None)
|
|
87
|
+
assert config.get_lifetime("users") is None
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import json
|
|
2
|
+
import os
|
|
3
|
+
import sys
|
|
4
|
+
|
|
5
|
+
sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), "..", "..")))
|
|
6
|
+
|
|
7
|
+
from amochka.etl import export_contacts_to_ndjson, write_ndjson
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def test_write_ndjson_transform_and_on_record(tmp_path):
|
|
11
|
+
records = [{"id": 1, "updated_at": 123}]
|
|
12
|
+
seen = []
|
|
13
|
+
|
|
14
|
+
def transform(record):
|
|
15
|
+
return {"id": record["id"], "flag": True}
|
|
16
|
+
|
|
17
|
+
def on_record(record):
|
|
18
|
+
seen.append(record["id"])
|
|
19
|
+
|
|
20
|
+
output_path = tmp_path / "out.ndjson"
|
|
21
|
+
count = write_ndjson(
|
|
22
|
+
records,
|
|
23
|
+
output_path,
|
|
24
|
+
entity="lead",
|
|
25
|
+
account_id=42,
|
|
26
|
+
transform=transform,
|
|
27
|
+
on_record=on_record,
|
|
28
|
+
)
|
|
29
|
+
assert count == 1
|
|
30
|
+
payload = json.loads(output_path.read_text().strip())
|
|
31
|
+
assert payload["entity"] == "lead"
|
|
32
|
+
assert payload["account_id"] == 42
|
|
33
|
+
assert payload["payload"]["flag"] is True
|
|
34
|
+
assert seen == [1]
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def test_export_contacts_handles_empty_response(tmp_path):
|
|
38
|
+
class DummyClient:
|
|
39
|
+
def _make_request(self, *args, **kwargs):
|
|
40
|
+
return None
|
|
41
|
+
|
|
42
|
+
def get_contact_by_id(self, *_args, **_kwargs):
|
|
43
|
+
raise Exception("not found")
|
|
44
|
+
|
|
45
|
+
def iter_contacts(self, *args, **kwargs):
|
|
46
|
+
return iter(())
|
|
47
|
+
|
|
48
|
+
client = DummyClient()
|
|
49
|
+
output_path = tmp_path / "contacts.ndjson"
|
|
50
|
+
count = export_contacts_to_ndjson(
|
|
51
|
+
client,
|
|
52
|
+
output_path,
|
|
53
|
+
account_id=1,
|
|
54
|
+
contact_ids=[123],
|
|
55
|
+
)
|
|
56
|
+
assert count == 0
|
|
57
|
+
assert output_path.read_text() == ""
|
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
import json
|
|
2
|
+
import os
|
|
3
|
+
import sys
|
|
4
|
+
import time
|
|
5
|
+
|
|
6
|
+
import pytest
|
|
7
|
+
|
|
8
|
+
sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), "..", "..")))
|
|
9
|
+
|
|
10
|
+
import amochka.client as client_module
|
|
11
|
+
from amochka import AmoCRMClient, CacheConfig, APIError, NotFoundError, RateLimitError
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class FakeResponse:
|
|
15
|
+
def __init__(self, status_code, json_data=None, text="", headers=None, raise_on_json=False):
|
|
16
|
+
self.status_code = status_code
|
|
17
|
+
self._json_data = json_data
|
|
18
|
+
self.text = text
|
|
19
|
+
self.headers = headers or {}
|
|
20
|
+
self._raise_on_json = raise_on_json
|
|
21
|
+
|
|
22
|
+
def json(self):
|
|
23
|
+
if self._raise_on_json:
|
|
24
|
+
raise ValueError("invalid json")
|
|
25
|
+
return self._json_data
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def make_client(max_retries=0):
|
|
29
|
+
token_data = {"access_token": "token", "expires_at": str(time.time() + 3600)}
|
|
30
|
+
return AmoCRMClient(
|
|
31
|
+
base_url="https://example.amocrm.ru",
|
|
32
|
+
token_file=json.dumps(token_data),
|
|
33
|
+
cache_config=CacheConfig.disabled(),
|
|
34
|
+
disable_logging=True,
|
|
35
|
+
max_retries=max_retries,
|
|
36
|
+
)
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def test_make_request_success_json(monkeypatch):
|
|
40
|
+
client = make_client()
|
|
41
|
+
|
|
42
|
+
def fake_request(*args, **kwargs):
|
|
43
|
+
return FakeResponse(200, json_data={"ok": True})
|
|
44
|
+
|
|
45
|
+
monkeypatch.setattr(client_module.requests, "request", fake_request)
|
|
46
|
+
assert client._make_request("GET", "/api/v4/leads") == {"ok": True}
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def test_make_request_invalid_json_raises(monkeypatch):
|
|
50
|
+
client = make_client()
|
|
51
|
+
|
|
52
|
+
def fake_request(*args, **kwargs):
|
|
53
|
+
return FakeResponse(200, text="not json", raise_on_json=True)
|
|
54
|
+
|
|
55
|
+
monkeypatch.setattr(client_module.requests, "request", fake_request)
|
|
56
|
+
with pytest.raises(APIError) as excinfo:
|
|
57
|
+
client._make_request("GET", "/api/v4/leads")
|
|
58
|
+
assert "Invalid JSON response" in str(excinfo.value)
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def test_make_request_204_returns_none(monkeypatch):
|
|
62
|
+
client = make_client()
|
|
63
|
+
|
|
64
|
+
def fake_request(*args, **kwargs):
|
|
65
|
+
return FakeResponse(204)
|
|
66
|
+
|
|
67
|
+
monkeypatch.setattr(client_module.requests, "request", fake_request)
|
|
68
|
+
assert client._make_request("DELETE", "/api/v4/leads/1") is None
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def test_make_request_404_raises(monkeypatch):
|
|
72
|
+
client = make_client()
|
|
73
|
+
|
|
74
|
+
def fake_request(*args, **kwargs):
|
|
75
|
+
return FakeResponse(404, text="not found")
|
|
76
|
+
|
|
77
|
+
monkeypatch.setattr(client_module.requests, "request", fake_request)
|
|
78
|
+
with pytest.raises(NotFoundError):
|
|
79
|
+
client._make_request("GET", "/api/v4/leads/404")
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def test_make_request_bad_request_non_sensitive(monkeypatch):
|
|
83
|
+
client = make_client()
|
|
84
|
+
|
|
85
|
+
def fake_request(*args, **kwargs):
|
|
86
|
+
return FakeResponse(400, text="bad request")
|
|
87
|
+
|
|
88
|
+
monkeypatch.setattr(client_module.requests, "request", fake_request)
|
|
89
|
+
with pytest.raises(APIError) as excinfo:
|
|
90
|
+
client._make_request("GET", "/api/v4/leads")
|
|
91
|
+
assert "bad request" in str(excinfo.value)
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def test_make_request_bad_request_sensitive(monkeypatch):
|
|
95
|
+
client = make_client()
|
|
96
|
+
|
|
97
|
+
def fake_request(*args, **kwargs):
|
|
98
|
+
return FakeResponse(400, text="secret token")
|
|
99
|
+
|
|
100
|
+
monkeypatch.setattr(client_module.requests, "request", fake_request)
|
|
101
|
+
with pytest.raises(APIError) as excinfo:
|
|
102
|
+
client._make_request("POST", "/oauth2/access_token")
|
|
103
|
+
assert "secret token" not in str(excinfo.value)
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def test_rate_limit_error_includes_retry_after(monkeypatch):
|
|
107
|
+
client = make_client(max_retries=0)
|
|
108
|
+
|
|
109
|
+
def fake_request(*args, **kwargs):
|
|
110
|
+
return FakeResponse(429, text="too many", headers={"Retry-After": "3"})
|
|
111
|
+
|
|
112
|
+
monkeypatch.setattr(client_module.requests, "request", fake_request)
|
|
113
|
+
with pytest.raises(RateLimitError) as excinfo:
|
|
114
|
+
client._make_request("GET", "/api/v4/leads")
|
|
115
|
+
assert excinfo.value.retry_after == "3"
|
|
116
|
+
assert "too many" in str(excinfo.value)
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def test_rate_limit_sensitive_hides_body(monkeypatch):
|
|
120
|
+
client = make_client(max_retries=0)
|
|
121
|
+
|
|
122
|
+
def fake_request(*args, **kwargs):
|
|
123
|
+
return FakeResponse(429, text="secret", headers={"Retry-After": "1"})
|
|
124
|
+
|
|
125
|
+
monkeypatch.setattr(client_module.requests, "request", fake_request)
|
|
126
|
+
with pytest.raises(RateLimitError) as excinfo:
|
|
127
|
+
client._make_request("GET", "/oauth2/access_token")
|
|
128
|
+
assert "secret" not in str(excinfo.value)
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def test_retries_then_succeeds(monkeypatch):
|
|
132
|
+
client = make_client(max_retries=1)
|
|
133
|
+
responses = [
|
|
134
|
+
FakeResponse(500, text="server error"),
|
|
135
|
+
FakeResponse(200, json_data={"ok": True}),
|
|
136
|
+
]
|
|
137
|
+
calls = []
|
|
138
|
+
|
|
139
|
+
def fake_request(*args, **kwargs):
|
|
140
|
+
calls.append(1)
|
|
141
|
+
return responses.pop(0)
|
|
142
|
+
|
|
143
|
+
monkeypatch.setattr(client_module.requests, "request", fake_request)
|
|
144
|
+
monkeypatch.setattr(client_module.time, "sleep", lambda *_: None)
|
|
145
|
+
assert client._make_request("GET", "/api/v4/leads") == {"ok": True}
|
|
146
|
+
assert len(calls) == 2
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
def test_401_triggers_refresh(monkeypatch):
|
|
150
|
+
client = make_client(max_retries=1)
|
|
151
|
+
client.refresh_token = "refresh"
|
|
152
|
+
client.client_id = "id"
|
|
153
|
+
client.client_secret = "secret"
|
|
154
|
+
client.redirect_uri = "https://example.amocrm.ru"
|
|
155
|
+
|
|
156
|
+
responses = [
|
|
157
|
+
FakeResponse(401, text="unauthorized"),
|
|
158
|
+
FakeResponse(200, json_data={"ok": True}),
|
|
159
|
+
]
|
|
160
|
+
refreshed = {"called": 0}
|
|
161
|
+
|
|
162
|
+
def fake_request(*args, **kwargs):
|
|
163
|
+
return responses.pop(0)
|
|
164
|
+
|
|
165
|
+
def fake_refresh():
|
|
166
|
+
refreshed["called"] += 1
|
|
167
|
+
client.token = "new-token"
|
|
168
|
+
|
|
169
|
+
monkeypatch.setattr(client_module.requests, "request", fake_request)
|
|
170
|
+
monkeypatch.setattr(client, "_refresh_access_token", fake_refresh)
|
|
171
|
+
assert client._make_request("GET", "/api/v4/leads") == {"ok": True}
|
|
172
|
+
assert refreshed["called"] == 1
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
def test_timeout_raises_api_error(monkeypatch):
|
|
176
|
+
client = make_client(max_retries=0)
|
|
177
|
+
|
|
178
|
+
def fake_request(*args, **kwargs):
|
|
179
|
+
raise client_module.requests.exceptions.Timeout("timeout")
|
|
180
|
+
|
|
181
|
+
monkeypatch.setattr(client_module.requests, "request", fake_request)
|
|
182
|
+
with pytest.raises(APIError):
|
|
183
|
+
client._make_request("GET", "/api/v4/leads")
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import json
|
|
2
|
+
import os
|
|
3
|
+
import sys
|
|
4
|
+
import time
|
|
5
|
+
|
|
6
|
+
sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), "..", "..")))
|
|
7
|
+
|
|
8
|
+
import pytest
|
|
9
|
+
|
|
10
|
+
from amochka import AmoCRMClient, CacheConfig, APIError
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def make_client():
|
|
14
|
+
token_data = {"access_token": "token", "expires_at": str(time.time() + 3600)}
|
|
15
|
+
return AmoCRMClient(
|
|
16
|
+
base_url="https://example.amocrm.ru",
|
|
17
|
+
token_file=json.dumps(token_data),
|
|
18
|
+
cache_config=CacheConfig.disabled(),
|
|
19
|
+
disable_logging=True,
|
|
20
|
+
)
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def test_get_entity_notes_handles_none_response(monkeypatch):
|
|
24
|
+
client = make_client()
|
|
25
|
+
monkeypatch.setattr(client, "_make_request", lambda *args, **kwargs: None)
|
|
26
|
+
assert client.get_entity_notes("lead", 1, get_all=True) == []
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def test_get_entity_events_handles_none_response(monkeypatch):
|
|
30
|
+
client = make_client()
|
|
31
|
+
monkeypatch.setattr(client, "_make_request", lambda *args, **kwargs: None)
|
|
32
|
+
assert client.get_entity_events("lead", 1, get_all=True) == []
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def test_get_entity_note_invalid_response_raises(monkeypatch):
|
|
36
|
+
client = make_client()
|
|
37
|
+
monkeypatch.setattr(client, "_make_request", lambda *args, **kwargs: None)
|
|
38
|
+
with pytest.raises(APIError):
|
|
39
|
+
client.get_entity_note("lead", 1, 2)
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def test_get_event_invalid_response_raises(monkeypatch):
|
|
43
|
+
client = make_client()
|
|
44
|
+
monkeypatch.setattr(client, "_make_request", lambda *args, **kwargs: None)
|
|
45
|
+
with pytest.raises(APIError):
|
|
46
|
+
client.get_event(123)
|
|
@@ -0,0 +1,211 @@
|
|
|
1
|
+
import json
|
|
2
|
+
import os
|
|
3
|
+
import sys
|
|
4
|
+
import time
|
|
5
|
+
|
|
6
|
+
import pytest
|
|
7
|
+
|
|
8
|
+
sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), "..", "..")))
|
|
9
|
+
|
|
10
|
+
import amochka.client as client_module
|
|
11
|
+
from amochka import AmoCRMClient, CacheConfig, AuthenticationError
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class FakeResponse:
|
|
15
|
+
def __init__(self, status_code, json_data=None, text="", headers=None):
|
|
16
|
+
self.status_code = status_code
|
|
17
|
+
self._json_data = json_data or {}
|
|
18
|
+
self.text = text
|
|
19
|
+
self.headers = headers or {}
|
|
20
|
+
|
|
21
|
+
def json(self):
|
|
22
|
+
return self._json_data
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def _token_payload(expires_at):
|
|
26
|
+
return {
|
|
27
|
+
"access_token": "token",
|
|
28
|
+
"refresh_token": "refresh",
|
|
29
|
+
"expires_at": expires_at,
|
|
30
|
+
"client_id": "client-id",
|
|
31
|
+
"client_secret": "client-secret",
|
|
32
|
+
"redirect_uri": "https://example.amocrm.ru",
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def test_validate_base_url_accepts_allowed_domains():
|
|
37
|
+
assert AmoCRMClient._validate_base_url("https://example.amocrm.ru/") == "https://example.amocrm.ru"
|
|
38
|
+
assert AmoCRMClient._validate_base_url("https://sub.amocrm.com") == "https://sub.amocrm.com"
|
|
39
|
+
assert AmoCRMClient._validate_base_url("https://demo.kommo.com/") == "https://demo.kommo.com"
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def test_validate_base_url_rejects_invalid():
|
|
43
|
+
with pytest.raises(ValueError):
|
|
44
|
+
AmoCRMClient._validate_base_url("http://example.amocrm.ru")
|
|
45
|
+
with pytest.raises(ValueError):
|
|
46
|
+
AmoCRMClient._validate_base_url("https://example.com")
|
|
47
|
+
with pytest.raises(ValueError):
|
|
48
|
+
AmoCRMClient._validate_base_url("https://example.amocrm.ru:8443")
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def test_mask_sensitive():
|
|
52
|
+
assert AmoCRMClient._mask_sensitive(None) == "***"
|
|
53
|
+
assert AmoCRMClient._mask_sensitive("abcd") == "***"
|
|
54
|
+
assert AmoCRMClient._mask_sensitive("abcdefghijkl") == "abcd...ijkl"
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def test_env_token_overrides_file(monkeypatch, tmp_path):
|
|
58
|
+
token_path = tmp_path / "token.json"
|
|
59
|
+
token_path.write_text(json.dumps(_token_payload(time.time() + 3600)))
|
|
60
|
+
|
|
61
|
+
monkeypatch.delenv("AMOCRM_ACCESS_TOKEN", raising=False)
|
|
62
|
+
monkeypatch.delenv("AMOCRM_EXPIRES_AT", raising=False)
|
|
63
|
+
monkeypatch.setenv("AMOCRM_ACCESS_TOKEN", "env-token")
|
|
64
|
+
monkeypatch.setenv("AMOCRM_EXPIRES_AT", str(time.time() + 3600))
|
|
65
|
+
|
|
66
|
+
client = AmoCRMClient(
|
|
67
|
+
base_url="https://example.amocrm.ru",
|
|
68
|
+
token_file=str(token_path),
|
|
69
|
+
cache_config=CacheConfig.disabled(),
|
|
70
|
+
disable_logging=True,
|
|
71
|
+
)
|
|
72
|
+
assert client.token == "env-token"
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def test_load_token_from_json_string():
|
|
76
|
+
payload = _token_payload(time.time() + 3600)
|
|
77
|
+
client = AmoCRMClient(
|
|
78
|
+
base_url="https://example.amocrm.ru",
|
|
79
|
+
token_file=json.dumps(payload),
|
|
80
|
+
cache_config=CacheConfig.disabled(),
|
|
81
|
+
disable_logging=True,
|
|
82
|
+
)
|
|
83
|
+
assert client.token == "token"
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def test_invalid_token_string_raises():
|
|
87
|
+
with pytest.raises(AuthenticationError):
|
|
88
|
+
AmoCRMClient(
|
|
89
|
+
base_url="https://example.amocrm.ru",
|
|
90
|
+
token_file="{bad json",
|
|
91
|
+
cache_config=CacheConfig.disabled(),
|
|
92
|
+
disable_logging=True,
|
|
93
|
+
)
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def test_missing_token_file_and_env_raises():
|
|
97
|
+
with pytest.raises(AuthenticationError):
|
|
98
|
+
AmoCRMClient(
|
|
99
|
+
base_url="https://example.amocrm.ru",
|
|
100
|
+
token_file=None,
|
|
101
|
+
cache_config=CacheConfig.disabled(),
|
|
102
|
+
disable_logging=True,
|
|
103
|
+
)
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def test_refresh_access_token_success(monkeypatch, tmp_path):
|
|
107
|
+
token_path = tmp_path / "token.json"
|
|
108
|
+
token_path.write_text(json.dumps(_token_payload(time.time() - 10)))
|
|
109
|
+
|
|
110
|
+
def fake_post(*args, **kwargs):
|
|
111
|
+
return FakeResponse(
|
|
112
|
+
200,
|
|
113
|
+
json_data={
|
|
114
|
+
"access_token": "new-token",
|
|
115
|
+
"refresh_token": "new-refresh",
|
|
116
|
+
"expires_in": 3600,
|
|
117
|
+
},
|
|
118
|
+
)
|
|
119
|
+
|
|
120
|
+
monkeypatch.setattr(client_module.requests, "post", fake_post)
|
|
121
|
+
|
|
122
|
+
client = AmoCRMClient(
|
|
123
|
+
base_url="https://example.amocrm.ru",
|
|
124
|
+
token_file=str(token_path),
|
|
125
|
+
cache_config=CacheConfig.disabled(),
|
|
126
|
+
disable_logging=True,
|
|
127
|
+
)
|
|
128
|
+
|
|
129
|
+
assert client.token == "new-token"
|
|
130
|
+
saved = json.loads(token_path.read_text())
|
|
131
|
+
assert saved["access_token"] == "new-token"
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def test_refresh_access_token_failure(monkeypatch, tmp_path):
|
|
135
|
+
token_path = tmp_path / "token.json"
|
|
136
|
+
token_path.write_text(json.dumps(_token_payload(time.time() - 10)))
|
|
137
|
+
|
|
138
|
+
def fake_post(*args, **kwargs):
|
|
139
|
+
return FakeResponse(400, text="bad")
|
|
140
|
+
|
|
141
|
+
monkeypatch.setattr(client_module.requests, "post", fake_post)
|
|
142
|
+
|
|
143
|
+
with pytest.raises(AuthenticationError):
|
|
144
|
+
AmoCRMClient(
|
|
145
|
+
base_url="https://example.amocrm.ru",
|
|
146
|
+
token_file=str(token_path),
|
|
147
|
+
cache_config=CacheConfig.disabled(),
|
|
148
|
+
disable_logging=True,
|
|
149
|
+
)
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
def test_refresh_access_token_invalid_json(monkeypatch, tmp_path):
|
|
153
|
+
token_path = tmp_path / "token.json"
|
|
154
|
+
token_path.write_text(json.dumps(_token_payload(time.time() - 10)))
|
|
155
|
+
|
|
156
|
+
class BadJsonResponse:
|
|
157
|
+
status_code = 200
|
|
158
|
+
text = "not json"
|
|
159
|
+
|
|
160
|
+
def json(self):
|
|
161
|
+
raise ValueError("invalid json")
|
|
162
|
+
|
|
163
|
+
monkeypatch.setattr(client_module.requests, "post", lambda *args, **kwargs: BadJsonResponse())
|
|
164
|
+
|
|
165
|
+
with pytest.raises(AuthenticationError):
|
|
166
|
+
AmoCRMClient(
|
|
167
|
+
base_url="https://example.amocrm.ru",
|
|
168
|
+
token_file=str(token_path),
|
|
169
|
+
cache_config=CacheConfig.disabled(),
|
|
170
|
+
disable_logging=True,
|
|
171
|
+
)
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
def test_cache_path_validation():
|
|
175
|
+
with pytest.raises(ValueError):
|
|
176
|
+
CacheConfig._validate_path("../secret", "base_dir")
|
|
177
|
+
with pytest.raises(ValueError):
|
|
178
|
+
CacheConfig._validate_path("", "file")
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
def test_cache_file_path_stays_in_base_dir(tmp_path):
|
|
182
|
+
token_path = tmp_path / "acct..name.json"
|
|
183
|
+
token_path.write_text(json.dumps(_token_payload(time.time() + 3600)))
|
|
184
|
+
cache_dir = tmp_path / "cache"
|
|
185
|
+
cache_config = CacheConfig.file_cache(base_dir=str(cache_dir))
|
|
186
|
+
|
|
187
|
+
client = AmoCRMClient(
|
|
188
|
+
base_url="https://example.amocrm.ru",
|
|
189
|
+
token_file=str(token_path),
|
|
190
|
+
cache_config=cache_config,
|
|
191
|
+
disable_logging=True,
|
|
192
|
+
)
|
|
193
|
+
|
|
194
|
+
cache_path = client._get_cache_file_path("users")
|
|
195
|
+
assert os.path.realpath(cache_path).startswith(os.path.realpath(str(cache_dir)))
|
|
196
|
+
assert ".." not in os.path.basename(cache_path)
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
def test_extract_account_name_from_token_file(tmp_path):
|
|
200
|
+
token_path = tmp_path / "accounts" / "bneginskogo_eng.json"
|
|
201
|
+
token_path.parent.mkdir(parents=True, exist_ok=True)
|
|
202
|
+
token_path.write_text(json.dumps(_token_payload(time.time() + 3600)))
|
|
203
|
+
|
|
204
|
+
client = AmoCRMClient(
|
|
205
|
+
base_url="https://example.amocrm.ru",
|
|
206
|
+
token_file=str(token_path),
|
|
207
|
+
cache_config=CacheConfig.disabled(),
|
|
208
|
+
disable_logging=True,
|
|
209
|
+
)
|
|
210
|
+
|
|
211
|
+
assert client._extract_account_name() == "eng"
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
import json
|
|
2
|
+
import os
|
|
3
|
+
import sys
|
|
4
|
+
import time
|
|
5
|
+
from datetime import datetime
|
|
6
|
+
|
|
7
|
+
import pytest
|
|
8
|
+
|
|
9
|
+
sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), "..", "..")))
|
|
10
|
+
|
|
11
|
+
from amochka import AmoCRMClient, CacheConfig
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class PaginationClient(AmoCRMClient):
|
|
15
|
+
def __init__(self, responses):
|
|
16
|
+
token_data = {"access_token": "token", "expires_at": str(time.time() + 3600)}
|
|
17
|
+
super().__init__(
|
|
18
|
+
base_url="https://example.amocrm.ru",
|
|
19
|
+
token_file=json.dumps(token_data),
|
|
20
|
+
cache_config=CacheConfig.disabled(),
|
|
21
|
+
disable_logging=True,
|
|
22
|
+
)
|
|
23
|
+
self._responses = list(responses)
|
|
24
|
+
self.calls = 0
|
|
25
|
+
|
|
26
|
+
def _make_request(self, method, endpoint, params=None, data=None, timeout=10):
|
|
27
|
+
self.calls += 1
|
|
28
|
+
return self._responses.pop(0)
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def make_client():
|
|
32
|
+
token_data = {"access_token": "token", "expires_at": str(time.time() + 3600)}
|
|
33
|
+
return AmoCRMClient(
|
|
34
|
+
base_url="https://example.amocrm.ru",
|
|
35
|
+
token_file=json.dumps(token_data),
|
|
36
|
+
cache_config=CacheConfig.disabled(),
|
|
37
|
+
disable_logging=True,
|
|
38
|
+
)
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def test_to_timestamp():
|
|
42
|
+
client = make_client()
|
|
43
|
+
dt = datetime(2020, 1, 1, 0, 0, 0)
|
|
44
|
+
assert client._to_timestamp(dt) == int(dt.timestamp())
|
|
45
|
+
assert client._to_timestamp(1700000000) == 1700000000
|
|
46
|
+
assert client._to_timestamp(1700000000.5) == 1700000000
|
|
47
|
+
iso = "2020-01-01T00:00:00"
|
|
48
|
+
assert client._to_timestamp(iso) == int(datetime.fromisoformat(iso).timestamp())
|
|
49
|
+
iso_z = "2020-01-01T00:00:00Z"
|
|
50
|
+
assert client._to_timestamp(iso_z) == int(datetime.fromisoformat("2020-01-01T00:00:00+00:00").timestamp())
|
|
51
|
+
with pytest.raises(ValueError):
|
|
52
|
+
client._to_timestamp("bad-date")
|
|
53
|
+
with pytest.raises(TypeError):
|
|
54
|
+
client._to_timestamp(object())
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def test_format_filter_values():
|
|
58
|
+
client = make_client()
|
|
59
|
+
assert client._format_filter_values(None) is None
|
|
60
|
+
assert client._format_filter_values(123) == "123"
|
|
61
|
+
assert client._format_filter_values(["1", 2]) == ["1", "2"]
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def test_extract_collection():
|
|
65
|
+
client = make_client()
|
|
66
|
+
response = {"_embedded": {"items": [{"id": 1}]}}
|
|
67
|
+
assert client._extract_collection(response, ("_embedded", "items")) == [{"id": 1}]
|
|
68
|
+
assert client._extract_collection(response, ("_embedded", "missing")) == []
|
|
69
|
+
assert client._extract_collection({"_embedded": {"items": {"id": 1}}}, ("_embedded", "items")) == []
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def test_iterate_paginated_links_next():
|
|
73
|
+
responses = [
|
|
74
|
+
{"_embedded": {"items": [{"id": 1}]}, "_links": {"next": {"href": "next"}}},
|
|
75
|
+
{"_embedded": {"items": [{"id": 2}]}, "_links": {}},
|
|
76
|
+
]
|
|
77
|
+
client = PaginationClient(responses)
|
|
78
|
+
items = list(client._iterate_paginated("/api/v4/items", data_path=("_embedded", "items")))
|
|
79
|
+
assert [item["id"] for item in items] == [1, 2]
|
|
80
|
+
assert client.calls == 2
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def test_iterate_paginated_max_pages():
|
|
84
|
+
responses = [
|
|
85
|
+
{"_embedded": {"items": [{"id": 1}]}, "_page_count": 3},
|
|
86
|
+
{"_embedded": {"items": [{"id": 2}]}, "_page_count": 3},
|
|
87
|
+
]
|
|
88
|
+
client = PaginationClient(responses)
|
|
89
|
+
items = list(client._iterate_paginated("/api/v4/items", data_path=("_embedded", "items"), max_pages=1))
|
|
90
|
+
assert [item["id"] for item in items] == [1]
|
|
91
|
+
assert client.calls == 1
|
|
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
|