amochka 0.4.0__tar.gz → 0.4.1__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.1}/PKG-INFO +1 -1
- {amochka-0.4.0 → amochka-0.4.1}/amochka/__init__.py +1 -1
- {amochka-0.4.0 → amochka-0.4.1}/amochka/client.py +6 -0
- {amochka-0.4.0 → amochka-0.4.1}/amochka.egg-info/PKG-INFO +1 -1
- {amochka-0.4.0 → amochka-0.4.1}/amochka.egg-info/SOURCES.txt +7 -1
- {amochka-0.4.0 → amochka-0.4.1}/pyproject.toml +1 -1
- amochka-0.4.1/tests/test_cache.py +87 -0
- amochka-0.4.1/tests/test_etl.py +34 -0
- amochka-0.4.1/tests/test_http.py +168 -0
- amochka-0.4.1/tests/test_notes_events.py +30 -0
- amochka-0.4.1/tests/test_security.py +179 -0
- amochka-0.4.1/tests/test_utils.py +89 -0
- {amochka-0.4.0 → amochka-0.4.1}/README.md +0 -0
- {amochka-0.4.0 → amochka-0.4.1}/amochka/errors.py +0 -0
- {amochka-0.4.0 → amochka-0.4.1}/amochka/etl.py +0 -0
- {amochka-0.4.0 → amochka-0.4.1}/amochka.egg-info/dependency_links.txt +0 -0
- {amochka-0.4.0 → amochka-0.4.1}/amochka.egg-info/requires.txt +0 -0
- {amochka-0.4.0 → amochka-0.4.1}/amochka.egg-info/top_level.txt +0 -0
- {amochka-0.4.0 → amochka-0.4.1}/etl/__init__.py +0 -0
- {amochka-0.4.0 → amochka-0.4.1}/etl/config.py +0 -0
- {amochka-0.4.0 → amochka-0.4.1}/etl/extractors.py +0 -0
- {amochka-0.4.0 → amochka-0.4.1}/etl/loaders.py +0 -0
- {amochka-0.4.0 → amochka-0.4.1}/etl/migrations/001_create_tables.sql +0 -0
- {amochka-0.4.0 → amochka-0.4.1}/etl/run_etl.py +0 -0
- {amochka-0.4.0 → amochka-0.4.1}/etl/transformers.py +0 -0
- {amochka-0.4.0 → amochka-0.4.1}/setup.cfg +0 -0
- {amochka-0.4.0 → amochka-0.4.1}/tests/test_client.py +0 -0
|
@@ -1591,6 +1591,9 @@ class AmoCRMClient:
|
|
|
1591
1591
|
notes = []
|
|
1592
1592
|
while True:
|
|
1593
1593
|
response = self._make_request("GET", endpoint, params=params)
|
|
1594
|
+
if not response:
|
|
1595
|
+
self.logger.warning(f"Empty response for notes {entity} {entity_id}, stopping pagination")
|
|
1596
|
+
break
|
|
1594
1597
|
if response and "_embedded" in response and "notes" in response["_embedded"]:
|
|
1595
1598
|
notes.extend(response["_embedded"]["notes"])
|
|
1596
1599
|
if not get_all:
|
|
@@ -1670,6 +1673,9 @@ class AmoCRMClient:
|
|
|
1670
1673
|
events = []
|
|
1671
1674
|
while True:
|
|
1672
1675
|
response = self._make_request("GET", "/api/v4/events", params=params)
|
|
1676
|
+
if not response:
|
|
1677
|
+
self.logger.warning(f"Empty response for events {entity} {entity_id}, stopping pagination")
|
|
1678
|
+
break
|
|
1673
1679
|
if response and "_embedded" in response and "events" in response["_embedded"]:
|
|
1674
1680
|
events.extend(response["_embedded"]["events"])
|
|
1675
1681
|
# Если не нужно получать все страницы, выходим
|
|
@@ -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,34 @@
|
|
|
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 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]
|
|
@@ -0,0 +1,168 @@
|
|
|
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):
|
|
16
|
+
self.status_code = status_code
|
|
17
|
+
self._json_data = json_data
|
|
18
|
+
self.text = text
|
|
19
|
+
self.headers = headers or {}
|
|
20
|
+
|
|
21
|
+
def json(self):
|
|
22
|
+
return self._json_data
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def make_client(max_retries=0):
|
|
26
|
+
token_data = {"access_token": "token", "expires_at": str(time.time() + 3600)}
|
|
27
|
+
return AmoCRMClient(
|
|
28
|
+
base_url="https://example.amocrm.ru",
|
|
29
|
+
token_file=json.dumps(token_data),
|
|
30
|
+
cache_config=CacheConfig.disabled(),
|
|
31
|
+
disable_logging=True,
|
|
32
|
+
max_retries=max_retries,
|
|
33
|
+
)
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def test_make_request_success_json(monkeypatch):
|
|
37
|
+
client = make_client()
|
|
38
|
+
|
|
39
|
+
def fake_request(*args, **kwargs):
|
|
40
|
+
return FakeResponse(200, json_data={"ok": True})
|
|
41
|
+
|
|
42
|
+
monkeypatch.setattr(client_module.requests, "request", fake_request)
|
|
43
|
+
assert client._make_request("GET", "/api/v4/leads") == {"ok": True}
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def test_make_request_204_returns_none(monkeypatch):
|
|
47
|
+
client = make_client()
|
|
48
|
+
|
|
49
|
+
def fake_request(*args, **kwargs):
|
|
50
|
+
return FakeResponse(204)
|
|
51
|
+
|
|
52
|
+
monkeypatch.setattr(client_module.requests, "request", fake_request)
|
|
53
|
+
assert client._make_request("DELETE", "/api/v4/leads/1") is None
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def test_make_request_404_raises(monkeypatch):
|
|
57
|
+
client = make_client()
|
|
58
|
+
|
|
59
|
+
def fake_request(*args, **kwargs):
|
|
60
|
+
return FakeResponse(404, text="not found")
|
|
61
|
+
|
|
62
|
+
monkeypatch.setattr(client_module.requests, "request", fake_request)
|
|
63
|
+
with pytest.raises(NotFoundError):
|
|
64
|
+
client._make_request("GET", "/api/v4/leads/404")
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def test_make_request_bad_request_non_sensitive(monkeypatch):
|
|
68
|
+
client = make_client()
|
|
69
|
+
|
|
70
|
+
def fake_request(*args, **kwargs):
|
|
71
|
+
return FakeResponse(400, text="bad request")
|
|
72
|
+
|
|
73
|
+
monkeypatch.setattr(client_module.requests, "request", fake_request)
|
|
74
|
+
with pytest.raises(APIError) as excinfo:
|
|
75
|
+
client._make_request("GET", "/api/v4/leads")
|
|
76
|
+
assert "bad request" in str(excinfo.value)
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def test_make_request_bad_request_sensitive(monkeypatch):
|
|
80
|
+
client = make_client()
|
|
81
|
+
|
|
82
|
+
def fake_request(*args, **kwargs):
|
|
83
|
+
return FakeResponse(400, text="secret token")
|
|
84
|
+
|
|
85
|
+
monkeypatch.setattr(client_module.requests, "request", fake_request)
|
|
86
|
+
with pytest.raises(APIError) as excinfo:
|
|
87
|
+
client._make_request("POST", "/oauth2/access_token")
|
|
88
|
+
assert "secret token" not in str(excinfo.value)
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def test_rate_limit_error_includes_retry_after(monkeypatch):
|
|
92
|
+
client = make_client(max_retries=0)
|
|
93
|
+
|
|
94
|
+
def fake_request(*args, **kwargs):
|
|
95
|
+
return FakeResponse(429, text="too many", headers={"Retry-After": "3"})
|
|
96
|
+
|
|
97
|
+
monkeypatch.setattr(client_module.requests, "request", fake_request)
|
|
98
|
+
with pytest.raises(RateLimitError) as excinfo:
|
|
99
|
+
client._make_request("GET", "/api/v4/leads")
|
|
100
|
+
assert excinfo.value.retry_after == "3"
|
|
101
|
+
assert "too many" in str(excinfo.value)
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def test_rate_limit_sensitive_hides_body(monkeypatch):
|
|
105
|
+
client = make_client(max_retries=0)
|
|
106
|
+
|
|
107
|
+
def fake_request(*args, **kwargs):
|
|
108
|
+
return FakeResponse(429, text="secret", headers={"Retry-After": "1"})
|
|
109
|
+
|
|
110
|
+
monkeypatch.setattr(client_module.requests, "request", fake_request)
|
|
111
|
+
with pytest.raises(RateLimitError) as excinfo:
|
|
112
|
+
client._make_request("GET", "/oauth2/access_token")
|
|
113
|
+
assert "secret" not in str(excinfo.value)
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def test_retries_then_succeeds(monkeypatch):
|
|
117
|
+
client = make_client(max_retries=1)
|
|
118
|
+
responses = [
|
|
119
|
+
FakeResponse(500, text="server error"),
|
|
120
|
+
FakeResponse(200, json_data={"ok": True}),
|
|
121
|
+
]
|
|
122
|
+
calls = []
|
|
123
|
+
|
|
124
|
+
def fake_request(*args, **kwargs):
|
|
125
|
+
calls.append(1)
|
|
126
|
+
return responses.pop(0)
|
|
127
|
+
|
|
128
|
+
monkeypatch.setattr(client_module.requests, "request", fake_request)
|
|
129
|
+
monkeypatch.setattr(client_module.time, "sleep", lambda *_: None)
|
|
130
|
+
assert client._make_request("GET", "/api/v4/leads") == {"ok": True}
|
|
131
|
+
assert len(calls) == 2
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def test_401_triggers_refresh(monkeypatch):
|
|
135
|
+
client = make_client(max_retries=1)
|
|
136
|
+
client.refresh_token = "refresh"
|
|
137
|
+
client.client_id = "id"
|
|
138
|
+
client.client_secret = "secret"
|
|
139
|
+
client.redirect_uri = "https://example.amocrm.ru"
|
|
140
|
+
|
|
141
|
+
responses = [
|
|
142
|
+
FakeResponse(401, text="unauthorized"),
|
|
143
|
+
FakeResponse(200, json_data={"ok": True}),
|
|
144
|
+
]
|
|
145
|
+
refreshed = {"called": 0}
|
|
146
|
+
|
|
147
|
+
def fake_request(*args, **kwargs):
|
|
148
|
+
return responses.pop(0)
|
|
149
|
+
|
|
150
|
+
def fake_refresh():
|
|
151
|
+
refreshed["called"] += 1
|
|
152
|
+
client.token = "new-token"
|
|
153
|
+
|
|
154
|
+
monkeypatch.setattr(client_module.requests, "request", fake_request)
|
|
155
|
+
monkeypatch.setattr(client, "_refresh_access_token", fake_refresh)
|
|
156
|
+
assert client._make_request("GET", "/api/v4/leads") == {"ok": True}
|
|
157
|
+
assert refreshed["called"] == 1
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
def test_timeout_raises_api_error(monkeypatch):
|
|
161
|
+
client = make_client(max_retries=0)
|
|
162
|
+
|
|
163
|
+
def fake_request(*args, **kwargs):
|
|
164
|
+
raise client_module.requests.exceptions.Timeout("timeout")
|
|
165
|
+
|
|
166
|
+
monkeypatch.setattr(client_module.requests, "request", fake_request)
|
|
167
|
+
with pytest.raises(APIError):
|
|
168
|
+
client._make_request("GET", "/api/v4/leads")
|
|
@@ -0,0 +1,30 @@
|
|
|
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
|
+
from amochka import AmoCRMClient, CacheConfig
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def make_client():
|
|
12
|
+
token_data = {"access_token": "token", "expires_at": str(time.time() + 3600)}
|
|
13
|
+
return AmoCRMClient(
|
|
14
|
+
base_url="https://example.amocrm.ru",
|
|
15
|
+
token_file=json.dumps(token_data),
|
|
16
|
+
cache_config=CacheConfig.disabled(),
|
|
17
|
+
disable_logging=True,
|
|
18
|
+
)
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def test_get_entity_notes_handles_none_response(monkeypatch):
|
|
22
|
+
client = make_client()
|
|
23
|
+
monkeypatch.setattr(client, "_make_request", lambda *args, **kwargs: None)
|
|
24
|
+
assert client.get_entity_notes("lead", 1, get_all=True) == []
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def test_get_entity_events_handles_none_response(monkeypatch):
|
|
28
|
+
client = make_client()
|
|
29
|
+
monkeypatch.setattr(client, "_make_request", lambda *args, **kwargs: None)
|
|
30
|
+
assert client.get_entity_events("lead", 1, get_all=True) == []
|
|
@@ -0,0 +1,179 @@
|
|
|
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_refresh_access_token_success(monkeypatch, tmp_path):
|
|
97
|
+
token_path = tmp_path / "token.json"
|
|
98
|
+
token_path.write_text(json.dumps(_token_payload(time.time() - 10)))
|
|
99
|
+
|
|
100
|
+
def fake_post(*args, **kwargs):
|
|
101
|
+
return FakeResponse(
|
|
102
|
+
200,
|
|
103
|
+
json_data={
|
|
104
|
+
"access_token": "new-token",
|
|
105
|
+
"refresh_token": "new-refresh",
|
|
106
|
+
"expires_in": 3600,
|
|
107
|
+
},
|
|
108
|
+
)
|
|
109
|
+
|
|
110
|
+
monkeypatch.setattr(client_module.requests, "post", fake_post)
|
|
111
|
+
|
|
112
|
+
client = AmoCRMClient(
|
|
113
|
+
base_url="https://example.amocrm.ru",
|
|
114
|
+
token_file=str(token_path),
|
|
115
|
+
cache_config=CacheConfig.disabled(),
|
|
116
|
+
disable_logging=True,
|
|
117
|
+
)
|
|
118
|
+
|
|
119
|
+
assert client.token == "new-token"
|
|
120
|
+
saved = json.loads(token_path.read_text())
|
|
121
|
+
assert saved["access_token"] == "new-token"
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def test_refresh_access_token_failure(monkeypatch, tmp_path):
|
|
125
|
+
token_path = tmp_path / "token.json"
|
|
126
|
+
token_path.write_text(json.dumps(_token_payload(time.time() - 10)))
|
|
127
|
+
|
|
128
|
+
def fake_post(*args, **kwargs):
|
|
129
|
+
return FakeResponse(400, text="bad")
|
|
130
|
+
|
|
131
|
+
monkeypatch.setattr(client_module.requests, "post", fake_post)
|
|
132
|
+
|
|
133
|
+
with pytest.raises(AuthenticationError):
|
|
134
|
+
AmoCRMClient(
|
|
135
|
+
base_url="https://example.amocrm.ru",
|
|
136
|
+
token_file=str(token_path),
|
|
137
|
+
cache_config=CacheConfig.disabled(),
|
|
138
|
+
disable_logging=True,
|
|
139
|
+
)
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def test_cache_path_validation():
|
|
143
|
+
with pytest.raises(ValueError):
|
|
144
|
+
CacheConfig._validate_path("../secret", "base_dir")
|
|
145
|
+
with pytest.raises(ValueError):
|
|
146
|
+
CacheConfig._validate_path("", "file")
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
def test_cache_file_path_stays_in_base_dir(tmp_path):
|
|
150
|
+
token_path = tmp_path / "acct..name.json"
|
|
151
|
+
token_path.write_text(json.dumps(_token_payload(time.time() + 3600)))
|
|
152
|
+
cache_dir = tmp_path / "cache"
|
|
153
|
+
cache_config = CacheConfig.file_cache(base_dir=str(cache_dir))
|
|
154
|
+
|
|
155
|
+
client = AmoCRMClient(
|
|
156
|
+
base_url="https://example.amocrm.ru",
|
|
157
|
+
token_file=str(token_path),
|
|
158
|
+
cache_config=cache_config,
|
|
159
|
+
disable_logging=True,
|
|
160
|
+
)
|
|
161
|
+
|
|
162
|
+
cache_path = client._get_cache_file_path("users")
|
|
163
|
+
assert os.path.realpath(cache_path).startswith(os.path.realpath(str(cache_dir)))
|
|
164
|
+
assert ".." not in os.path.basename(cache_path)
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
def test_extract_account_name_from_token_file(tmp_path):
|
|
168
|
+
token_path = tmp_path / "accounts" / "bneginskogo_eng.json"
|
|
169
|
+
token_path.parent.mkdir(parents=True, exist_ok=True)
|
|
170
|
+
token_path.write_text(json.dumps(_token_payload(time.time() + 3600)))
|
|
171
|
+
|
|
172
|
+
client = AmoCRMClient(
|
|
173
|
+
base_url="https://example.amocrm.ru",
|
|
174
|
+
token_file=str(token_path),
|
|
175
|
+
cache_config=CacheConfig.disabled(),
|
|
176
|
+
disable_logging=True,
|
|
177
|
+
)
|
|
178
|
+
|
|
179
|
+
assert client._extract_account_name() == "eng"
|
|
@@ -0,0 +1,89 @@
|
|
|
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
|
+
with pytest.raises(ValueError):
|
|
50
|
+
client._to_timestamp("bad-date")
|
|
51
|
+
with pytest.raises(TypeError):
|
|
52
|
+
client._to_timestamp(object())
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def test_format_filter_values():
|
|
56
|
+
client = make_client()
|
|
57
|
+
assert client._format_filter_values(None) is None
|
|
58
|
+
assert client._format_filter_values(123) == "123"
|
|
59
|
+
assert client._format_filter_values(["1", 2]) == ["1", "2"]
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def test_extract_collection():
|
|
63
|
+
client = make_client()
|
|
64
|
+
response = {"_embedded": {"items": [{"id": 1}]}}
|
|
65
|
+
assert client._extract_collection(response, ("_embedded", "items")) == [{"id": 1}]
|
|
66
|
+
assert client._extract_collection(response, ("_embedded", "missing")) == []
|
|
67
|
+
assert client._extract_collection({"_embedded": {"items": {"id": 1}}}, ("_embedded", "items")) == []
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def test_iterate_paginated_links_next():
|
|
71
|
+
responses = [
|
|
72
|
+
{"_embedded": {"items": [{"id": 1}]}, "_links": {"next": {"href": "next"}}},
|
|
73
|
+
{"_embedded": {"items": [{"id": 2}]}, "_links": {}},
|
|
74
|
+
]
|
|
75
|
+
client = PaginationClient(responses)
|
|
76
|
+
items = list(client._iterate_paginated("/api/v4/items", data_path=("_embedded", "items")))
|
|
77
|
+
assert [item["id"] for item in items] == [1, 2]
|
|
78
|
+
assert client.calls == 2
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def test_iterate_paginated_max_pages():
|
|
82
|
+
responses = [
|
|
83
|
+
{"_embedded": {"items": [{"id": 1}]}, "_page_count": 3},
|
|
84
|
+
{"_embedded": {"items": [{"id": 2}]}, "_page_count": 3},
|
|
85
|
+
]
|
|
86
|
+
client = PaginationClient(responses)
|
|
87
|
+
items = list(client._iterate_paginated("/api/v4/items", data_path=("_embedded", "items"), max_pages=1))
|
|
88
|
+
assert [item["id"] for item in items] == [1]
|
|
89
|
+
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
|
|
File without changes
|