tollmeshcache 1.0.0__py3-none-any.whl
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.
- tests/__init__.py +1 -0
- tests/test_async_client.py +228 -0
- tests/test_client.py +312 -0
- tollmeshcache/__init__.py +45 -0
- tollmeshcache/async_client.py +235 -0
- tollmeshcache/client.py +288 -0
- tollmeshcache/config.py +65 -0
- tollmeshcache/errors.py +154 -0
- tollmeshcache/retry.py +154 -0
- tollmeshcache-1.0.0.dist-info/METADATA +319 -0
- tollmeshcache-1.0.0.dist-info/RECORD +14 -0
- tollmeshcache-1.0.0.dist-info/WHEEL +5 -0
- tollmeshcache-1.0.0.dist-info/entry_points.txt +3 -0
- tollmeshcache-1.0.0.dist-info/top_level.txt +2 -0
tests/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Test suite for TollMeshCache Python SDK"""
|
|
@@ -0,0 +1,228 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Async tests for TollMeshCache Python SDK
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
import pytest
|
|
6
|
+
import pytest_asyncio
|
|
7
|
+
from datetime import timedelta
|
|
8
|
+
from unittest.mock import AsyncMock, MagicMock, patch
|
|
9
|
+
import json
|
|
10
|
+
|
|
11
|
+
from tollmeshcache import AsyncClient, ClientConfig
|
|
12
|
+
from tollmeshcache.errors import TollMeshError, ErrorCode
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
@pytest.fixture
|
|
16
|
+
def async_client_config():
|
|
17
|
+
"""Create a test async client configuration"""
|
|
18
|
+
return ClientConfig(
|
|
19
|
+
host="localhost",
|
|
20
|
+
port=8080,
|
|
21
|
+
timeout=5.0,
|
|
22
|
+
)
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
@pytest_asyncio.fixture
|
|
26
|
+
async def async_client(async_client_config):
|
|
27
|
+
"""Create a test async client"""
|
|
28
|
+
client = AsyncClient(async_client_config)
|
|
29
|
+
yield client
|
|
30
|
+
await client.close()
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class TestAsyncClient:
|
|
34
|
+
"""Tests for AsyncClient"""
|
|
35
|
+
|
|
36
|
+
@pytest.mark.asyncio
|
|
37
|
+
async def test_async_client_creation(self, async_client):
|
|
38
|
+
"""Test async client creation"""
|
|
39
|
+
assert async_client is not None
|
|
40
|
+
assert async_client.config.host == "localhost"
|
|
41
|
+
|
|
42
|
+
@pytest.mark.asyncio
|
|
43
|
+
async def test_async_context_manager(self, async_client_config):
|
|
44
|
+
"""Test async client as context manager"""
|
|
45
|
+
async with AsyncClient(async_client_config) as client:
|
|
46
|
+
assert client is not None
|
|
47
|
+
|
|
48
|
+
@pytest.mark.asyncio
|
|
49
|
+
async def test_consume_success(self, async_client):
|
|
50
|
+
"""Test successful consume"""
|
|
51
|
+
with patch.object(async_client.client, 'post', new_callable=AsyncMock) as mock_post:
|
|
52
|
+
mock_response = MagicMock()
|
|
53
|
+
mock_response.json.return_value = {
|
|
54
|
+
"ok": True,
|
|
55
|
+
"remaining": 99,
|
|
56
|
+
"reset_at": 1234567890000,
|
|
57
|
+
}
|
|
58
|
+
mock_post.return_value = mock_response
|
|
59
|
+
|
|
60
|
+
result = await async_client.consume("user-123", 100, timedelta(minutes=1))
|
|
61
|
+
|
|
62
|
+
assert result["ok"] is True
|
|
63
|
+
assert result["remaining"] == 99
|
|
64
|
+
assert result["reset_at"] == 1234567890000
|
|
65
|
+
|
|
66
|
+
@pytest.mark.asyncio
|
|
67
|
+
async def test_seen_replay(self, async_client):
|
|
68
|
+
"""Test seen when replay"""
|
|
69
|
+
with patch.object(async_client.client, 'post', new_callable=AsyncMock) as mock_post:
|
|
70
|
+
mock_response = MagicMock()
|
|
71
|
+
mock_response.json.return_value = {"seen": True}
|
|
72
|
+
mock_post.return_value = mock_response
|
|
73
|
+
|
|
74
|
+
result = await async_client.seen("nonce-123", timedelta(minutes=5))
|
|
75
|
+
|
|
76
|
+
assert result["seen"] is True
|
|
77
|
+
|
|
78
|
+
@pytest.mark.asyncio
|
|
79
|
+
async def test_cache_get_hit(self, async_client):
|
|
80
|
+
"""Test cache get with hit"""
|
|
81
|
+
with patch.object(async_client.client, 'post', new_callable=AsyncMock) as mock_post:
|
|
82
|
+
mock_response = MagicMock()
|
|
83
|
+
mock_response.json.return_value = {
|
|
84
|
+
"value": "test-value",
|
|
85
|
+
"exists": True,
|
|
86
|
+
}
|
|
87
|
+
mock_post.return_value = mock_response
|
|
88
|
+
|
|
89
|
+
value, exists = await async_client.cache_get("users", "user-123")
|
|
90
|
+
|
|
91
|
+
assert value == "test-value"
|
|
92
|
+
assert exists is True
|
|
93
|
+
|
|
94
|
+
@pytest.mark.asyncio
|
|
95
|
+
async def test_cache_set(self, async_client):
|
|
96
|
+
"""Test cache set"""
|
|
97
|
+
with patch.object(async_client.client, 'post', new_callable=AsyncMock) as mock_post:
|
|
98
|
+
mock_response = MagicMock()
|
|
99
|
+
mock_response.json.return_value = {}
|
|
100
|
+
mock_post.return_value = mock_response
|
|
101
|
+
|
|
102
|
+
await async_client.cache_set("users", "user-123", "test-value", timedelta(hours=1))
|
|
103
|
+
mock_post.assert_called_once()
|
|
104
|
+
|
|
105
|
+
@pytest.mark.asyncio
|
|
106
|
+
async def test_health(self, async_client):
|
|
107
|
+
"""Test health check"""
|
|
108
|
+
with patch.object(async_client.client, 'get', new_callable=AsyncMock) as mock_get:
|
|
109
|
+
mock_response = MagicMock()
|
|
110
|
+
mock_response.json.return_value = {
|
|
111
|
+
"status": "healthy",
|
|
112
|
+
"node": "node-1",
|
|
113
|
+
"peers": 3,
|
|
114
|
+
}
|
|
115
|
+
mock_get.return_value = mock_response
|
|
116
|
+
|
|
117
|
+
health = await async_client.health()
|
|
118
|
+
|
|
119
|
+
assert health["status"] == "healthy"
|
|
120
|
+
assert health["peers"] == 3
|
|
121
|
+
|
|
122
|
+
@pytest.mark.asyncio
|
|
123
|
+
async def test_get_peers(self, async_client):
|
|
124
|
+
"""Test get peers"""
|
|
125
|
+
with patch.object(async_client.client, 'get', new_callable=AsyncMock) as mock_get:
|
|
126
|
+
mock_response = MagicMock()
|
|
127
|
+
mock_response.json.return_value = {
|
|
128
|
+
"peers": [
|
|
129
|
+
{"id": "node-1", "address": "localhost", "port": 8080, "latency_ms": 5},
|
|
130
|
+
{"id": "node-2", "address": "localhost", "port": 8081, "latency_ms": 10},
|
|
131
|
+
]
|
|
132
|
+
}
|
|
133
|
+
mock_get.return_value = mock_response
|
|
134
|
+
|
|
135
|
+
peers = await async_client.get_peers()
|
|
136
|
+
|
|
137
|
+
assert len(peers) == 2
|
|
138
|
+
assert peers[0]["id"] == "node-1"
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
class TestAsyncConcurrency:
|
|
142
|
+
"""Tests for async concurrency"""
|
|
143
|
+
|
|
144
|
+
@pytest.mark.asyncio
|
|
145
|
+
async def test_concurrent_requests(self, async_client):
|
|
146
|
+
"""Test multiple concurrent requests"""
|
|
147
|
+
import asyncio
|
|
148
|
+
|
|
149
|
+
with patch.object(async_client.client, 'post', new_callable=AsyncMock) as mock_post:
|
|
150
|
+
mock_response = MagicMock()
|
|
151
|
+
mock_response.json.return_value = {
|
|
152
|
+
"ok": True,
|
|
153
|
+
"remaining": 99,
|
|
154
|
+
"reset_at": 1234567890000,
|
|
155
|
+
}
|
|
156
|
+
mock_post.return_value = mock_response
|
|
157
|
+
|
|
158
|
+
# Create 5 concurrent requests
|
|
159
|
+
tasks = [
|
|
160
|
+
async_client.consume(f"user-{i}", 100, timedelta(minutes=1))
|
|
161
|
+
for i in range(5)
|
|
162
|
+
]
|
|
163
|
+
|
|
164
|
+
results = await asyncio.gather(*tasks)
|
|
165
|
+
|
|
166
|
+
assert len(results) == 5
|
|
167
|
+
assert all(r["ok"] is True for r in results)
|
|
168
|
+
assert mock_post.call_count >= 5
|
|
169
|
+
|
|
170
|
+
@pytest.mark.asyncio
|
|
171
|
+
async def test_concurrent_cache_operations(self, async_client):
|
|
172
|
+
"""Test concurrent cache operations"""
|
|
173
|
+
import asyncio
|
|
174
|
+
|
|
175
|
+
with patch.object(async_client.client, 'post', new_callable=AsyncMock) as mock_post:
|
|
176
|
+
# First call sets cache
|
|
177
|
+
set_response = MagicMock()
|
|
178
|
+
set_response.json.return_value = {}
|
|
179
|
+
|
|
180
|
+
# Second call gets cache
|
|
181
|
+
get_response = MagicMock()
|
|
182
|
+
get_response.json.return_value = {
|
|
183
|
+
"value": "test-data",
|
|
184
|
+
"exists": True,
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
mock_post.side_effect = [set_response, get_response]
|
|
188
|
+
|
|
189
|
+
# Concurrent set and get
|
|
190
|
+
async def set_cache():
|
|
191
|
+
await async_client.cache_set("data", "key-1", "test-data", timedelta(hours=1))
|
|
192
|
+
|
|
193
|
+
async def get_cache():
|
|
194
|
+
return await async_client.cache_get("data", "key-1")
|
|
195
|
+
|
|
196
|
+
tasks = [set_cache(), get_cache()]
|
|
197
|
+
results = await asyncio.gather(*tasks, return_exceptions=True)
|
|
198
|
+
|
|
199
|
+
# At least one should complete without exception
|
|
200
|
+
assert any(r is None or not isinstance(r, Exception) for r in results)
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
class TestAsyncErrorHandling:
|
|
204
|
+
"""Tests for async error handling"""
|
|
205
|
+
|
|
206
|
+
@pytest.mark.asyncio
|
|
207
|
+
async def test_async_error_handling(self, async_client):
|
|
208
|
+
"""Test async error handling"""
|
|
209
|
+
import httpx
|
|
210
|
+
|
|
211
|
+
with patch.object(async_client.client, 'post', new_callable=AsyncMock) as mock_post:
|
|
212
|
+
# Simulate HTTP error
|
|
213
|
+
error_response = MagicMock()
|
|
214
|
+
error_response.status_code = 500
|
|
215
|
+
error_response.json.return_value = {
|
|
216
|
+
"code": 500,
|
|
217
|
+
"message": "Internal server error",
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
http_error = httpx.HTTPStatusError("Error", request=MagicMock(), response=error_response)
|
|
221
|
+
mock_post.side_effect = http_error
|
|
222
|
+
|
|
223
|
+
with pytest.raises(TollMeshError):
|
|
224
|
+
await async_client.consume("key", 100, timedelta(minutes=1))
|
|
225
|
+
|
|
226
|
+
|
|
227
|
+
if __name__ == "__main__":
|
|
228
|
+
pytest.main([__file__, "-v"])
|
tests/test_client.py
ADDED
|
@@ -0,0 +1,312 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Unit tests for TollMeshCache Python SDK
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
import pytest
|
|
6
|
+
from datetime import timedelta
|
|
7
|
+
from unittest.mock import Mock, patch, MagicMock
|
|
8
|
+
import json
|
|
9
|
+
|
|
10
|
+
from tollmeshcache import Client, ClientConfig
|
|
11
|
+
from tollmeshcache.errors import TollMeshError, ErrorCode, ReplayError
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
@pytest.fixture
|
|
15
|
+
def client_config():
|
|
16
|
+
"""Create a test client configuration"""
|
|
17
|
+
return ClientConfig(
|
|
18
|
+
host="localhost",
|
|
19
|
+
port=8080,
|
|
20
|
+
timeout=5.0,
|
|
21
|
+
)
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
@pytest.fixture
|
|
25
|
+
def client(client_config):
|
|
26
|
+
"""Create a test client"""
|
|
27
|
+
return Client(client_config)
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class TestClientConfig:
|
|
31
|
+
"""Tests for ClientConfig"""
|
|
32
|
+
|
|
33
|
+
def test_default_config(self):
|
|
34
|
+
"""Test default configuration"""
|
|
35
|
+
config = ClientConfig()
|
|
36
|
+
assert config.host == "localhost"
|
|
37
|
+
assert config.port == 8080
|
|
38
|
+
assert config.timeout == 5.0
|
|
39
|
+
assert config.verify_ssl is True
|
|
40
|
+
assert config.http_scheme == "http"
|
|
41
|
+
|
|
42
|
+
def test_custom_config(self):
|
|
43
|
+
"""Test custom configuration"""
|
|
44
|
+
config = ClientConfig(
|
|
45
|
+
host="api.example.com",
|
|
46
|
+
port=443,
|
|
47
|
+
http_scheme="https",
|
|
48
|
+
api_key="secret-key",
|
|
49
|
+
)
|
|
50
|
+
assert config.host == "api.example.com"
|
|
51
|
+
assert config.port == 443
|
|
52
|
+
assert config.http_scheme == "https"
|
|
53
|
+
assert config.api_key == "secret-key"
|
|
54
|
+
|
|
55
|
+
def test_base_url_http(self):
|
|
56
|
+
"""Test base URL for HTTP"""
|
|
57
|
+
config = ClientConfig(host="localhost", port=8080, http_scheme="http")
|
|
58
|
+
assert config.base_url == "http://localhost:8080"
|
|
59
|
+
|
|
60
|
+
def test_base_url_https(self):
|
|
61
|
+
"""Test base URL for HTTPS"""
|
|
62
|
+
config = ClientConfig(host="api.example.com", port=443, http_scheme="https")
|
|
63
|
+
assert config.base_url == "https://api.example.com:443"
|
|
64
|
+
|
|
65
|
+
def test_invalid_port(self):
|
|
66
|
+
"""Test invalid port validation"""
|
|
67
|
+
with pytest.raises(ValueError):
|
|
68
|
+
ClientConfig(port=0)
|
|
69
|
+
with pytest.raises(ValueError):
|
|
70
|
+
ClientConfig(port=65536)
|
|
71
|
+
|
|
72
|
+
def test_invalid_timeout(self):
|
|
73
|
+
"""Test invalid timeout validation"""
|
|
74
|
+
with pytest.raises(ValueError):
|
|
75
|
+
ClientConfig(timeout=0)
|
|
76
|
+
with pytest.raises(ValueError):
|
|
77
|
+
ClientConfig(timeout=-1)
|
|
78
|
+
|
|
79
|
+
def test_invalid_scheme(self):
|
|
80
|
+
"""Test invalid scheme validation"""
|
|
81
|
+
with pytest.raises(ValueError):
|
|
82
|
+
ClientConfig(http_scheme="ftp")
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
class TestClientInitialization:
|
|
86
|
+
"""Tests for client initialization"""
|
|
87
|
+
|
|
88
|
+
def test_client_creation(self, client):
|
|
89
|
+
"""Test client creation"""
|
|
90
|
+
assert client is not None
|
|
91
|
+
assert client.config.host == "localhost"
|
|
92
|
+
|
|
93
|
+
def test_client_with_api_key(self, client_config):
|
|
94
|
+
"""Test client with API key"""
|
|
95
|
+
client_config.api_key = "test-key"
|
|
96
|
+
client = Client(client_config)
|
|
97
|
+
assert client.config.api_key == "test-key"
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
class TestConsumeOperation:
|
|
101
|
+
"""Tests for rate limiting consume operation"""
|
|
102
|
+
|
|
103
|
+
@patch('tollmeshcache.client.requests.Session.post')
|
|
104
|
+
def test_consume_success(self, mock_post, client):
|
|
105
|
+
"""Test successful consume"""
|
|
106
|
+
mock_response = Mock()
|
|
107
|
+
mock_response.status_code = 200
|
|
108
|
+
mock_response.json.return_value = {
|
|
109
|
+
"ok": True,
|
|
110
|
+
"remaining": 99,
|
|
111
|
+
"reset_at": 1234567890000,
|
|
112
|
+
}
|
|
113
|
+
mock_post.return_value = mock_response
|
|
114
|
+
|
|
115
|
+
result = client.consume("user-123", 100, timedelta(minutes=1))
|
|
116
|
+
|
|
117
|
+
assert result["ok"] is True
|
|
118
|
+
assert result["remaining"] == 99
|
|
119
|
+
assert result["reset_at"] == 1234567890000
|
|
120
|
+
|
|
121
|
+
@patch('tollmeshcache.client.requests.Session.post')
|
|
122
|
+
def test_consume_rate_limited(self, mock_post, client):
|
|
123
|
+
"""Test consume when rate limited"""
|
|
124
|
+
mock_response = Mock()
|
|
125
|
+
mock_response.status_code = 200
|
|
126
|
+
mock_response.json.return_value = {
|
|
127
|
+
"ok": False,
|
|
128
|
+
"remaining": 0,
|
|
129
|
+
"reset_at": 1234567890000,
|
|
130
|
+
}
|
|
131
|
+
mock_post.return_value = mock_response
|
|
132
|
+
|
|
133
|
+
result = client.consume("user-123", 10, timedelta(seconds=1))
|
|
134
|
+
|
|
135
|
+
assert result["ok"] is False
|
|
136
|
+
assert result["remaining"] == 0
|
|
137
|
+
|
|
138
|
+
@patch('tollmeshcache.client.requests.Session.post')
|
|
139
|
+
def test_consume_error(self, mock_post, client):
|
|
140
|
+
"""Test consume with error"""
|
|
141
|
+
mock_response = Mock()
|
|
142
|
+
mock_response.status_code = 500
|
|
143
|
+
mock_response.json.return_value = {
|
|
144
|
+
"code": 500,
|
|
145
|
+
"message": "Internal server error",
|
|
146
|
+
}
|
|
147
|
+
mock_response.raise_for_status.side_effect = Exception("HTTP 500")
|
|
148
|
+
mock_post.return_value = mock_response
|
|
149
|
+
|
|
150
|
+
with pytest.raises(TollMeshError):
|
|
151
|
+
client.consume("user-123", 100, timedelta(minutes=1))
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
class TestSeenOperation:
|
|
155
|
+
"""Tests for replay protection seen operation"""
|
|
156
|
+
|
|
157
|
+
@patch('tollmeshcache.client.requests.Session.post')
|
|
158
|
+
def test_seen_first_time(self, mock_post, client):
|
|
159
|
+
"""Test seen when nonce is new"""
|
|
160
|
+
mock_response = Mock()
|
|
161
|
+
mock_response.status_code = 200
|
|
162
|
+
mock_response.json.return_value = {"seen": False}
|
|
163
|
+
mock_post.return_value = mock_response
|
|
164
|
+
|
|
165
|
+
result = client.seen("nonce-123", timedelta(minutes=5))
|
|
166
|
+
|
|
167
|
+
assert result["seen"] is False
|
|
168
|
+
|
|
169
|
+
@patch('tollmeshcache.client.requests.Session.post')
|
|
170
|
+
def test_seen_replay(self, mock_post, client):
|
|
171
|
+
"""Test seen when nonce is replay"""
|
|
172
|
+
mock_response = Mock()
|
|
173
|
+
mock_response.status_code = 200
|
|
174
|
+
mock_response.json.return_value = {"seen": True}
|
|
175
|
+
mock_post.return_value = mock_response
|
|
176
|
+
|
|
177
|
+
result = client.seen("nonce-123", timedelta(minutes=5))
|
|
178
|
+
|
|
179
|
+
assert result["seen"] is True
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
class TestCacheOperations:
|
|
183
|
+
"""Tests for caching operations"""
|
|
184
|
+
|
|
185
|
+
@patch('tollmeshcache.client.requests.Session.post')
|
|
186
|
+
def test_cache_set(self, mock_post, client):
|
|
187
|
+
"""Test cache set"""
|
|
188
|
+
mock_response = Mock()
|
|
189
|
+
mock_response.status_code = 200
|
|
190
|
+
mock_response.json.return_value = {}
|
|
191
|
+
mock_post.return_value = mock_response
|
|
192
|
+
|
|
193
|
+
client.cache_set("users", "user-123", "test-value", timedelta(hours=1))
|
|
194
|
+
mock_post.assert_called_once()
|
|
195
|
+
|
|
196
|
+
@patch('tollmeshcache.client.requests.Session.post')
|
|
197
|
+
def test_cache_get_hit(self, mock_post, client):
|
|
198
|
+
"""Test cache get with hit"""
|
|
199
|
+
mock_response = Mock()
|
|
200
|
+
mock_response.status_code = 200
|
|
201
|
+
mock_response.json.return_value = {
|
|
202
|
+
"value": "test-value",
|
|
203
|
+
"exists": True,
|
|
204
|
+
}
|
|
205
|
+
mock_post.return_value = mock_response
|
|
206
|
+
|
|
207
|
+
value, exists = client.cache_get("users", "user-123")
|
|
208
|
+
|
|
209
|
+
assert value == "test-value"
|
|
210
|
+
assert exists is True
|
|
211
|
+
|
|
212
|
+
@patch('tollmeshcache.client.requests.Session.post')
|
|
213
|
+
def test_cache_get_miss(self, mock_post, client):
|
|
214
|
+
"""Test cache get with miss"""
|
|
215
|
+
mock_response = Mock()
|
|
216
|
+
mock_response.status_code = 200
|
|
217
|
+
mock_response.json.return_value = {
|
|
218
|
+
"value": None,
|
|
219
|
+
"exists": False,
|
|
220
|
+
}
|
|
221
|
+
mock_post.return_value = mock_response
|
|
222
|
+
|
|
223
|
+
value, exists = client.cache_get("users", "user-999")
|
|
224
|
+
|
|
225
|
+
assert value is None
|
|
226
|
+
assert exists is False
|
|
227
|
+
|
|
228
|
+
|
|
229
|
+
class TestHealthOperation:
|
|
230
|
+
"""Tests for health check"""
|
|
231
|
+
|
|
232
|
+
@patch('tollmeshcache.client.requests.Session.get')
|
|
233
|
+
def test_health_healthy(self, mock_get, client):
|
|
234
|
+
"""Test health check when healthy"""
|
|
235
|
+
mock_response = Mock()
|
|
236
|
+
mock_response.status_code = 200
|
|
237
|
+
mock_response.json.return_value = {
|
|
238
|
+
"status": "healthy",
|
|
239
|
+
"node": "node-1",
|
|
240
|
+
"peers": 3,
|
|
241
|
+
}
|
|
242
|
+
mock_get.return_value = mock_response
|
|
243
|
+
|
|
244
|
+
health = client.health()
|
|
245
|
+
|
|
246
|
+
assert health["status"] == "healthy"
|
|
247
|
+
assert health["node"] == "node-1"
|
|
248
|
+
assert health["peers"] == 3
|
|
249
|
+
|
|
250
|
+
|
|
251
|
+
class TestErrorHandling:
|
|
252
|
+
"""Tests for error handling"""
|
|
253
|
+
|
|
254
|
+
def test_error_code_enum(self):
|
|
255
|
+
"""Test error code enum"""
|
|
256
|
+
assert ErrorCode.OK == 0
|
|
257
|
+
assert ErrorCode.INVALID_REQUEST == 400
|
|
258
|
+
assert ErrorCode.RATE_LIMITED == 429
|
|
259
|
+
assert ErrorCode.INTERNAL == 500
|
|
260
|
+
|
|
261
|
+
def test_tollmesh_error(self):
|
|
262
|
+
"""Test TollMeshError"""
|
|
263
|
+
error = TollMeshError(
|
|
264
|
+
ErrorCode.RATE_LIMITED,
|
|
265
|
+
"Rate limit exceeded"
|
|
266
|
+
)
|
|
267
|
+
assert error.code == ErrorCode.RATE_LIMITED
|
|
268
|
+
assert error.message == "Rate limit exceeded"
|
|
269
|
+
assert error.is_rate_limited()
|
|
270
|
+
assert not error.is_replay()
|
|
271
|
+
|
|
272
|
+
def test_replay_error(self):
|
|
273
|
+
"""Test ReplayError"""
|
|
274
|
+
error = ReplayError("nonce-123")
|
|
275
|
+
assert error.nonce == "nonce-123"
|
|
276
|
+
assert error.is_replay()
|
|
277
|
+
assert not error.is_rate_limited()
|
|
278
|
+
|
|
279
|
+
def test_error_string_representation(self):
|
|
280
|
+
"""Test error string representation"""
|
|
281
|
+
error = TollMeshError(ErrorCode.INTERNAL, "Server error")
|
|
282
|
+
error_str = str(error)
|
|
283
|
+
assert "429" in str(ErrorCode.RATE_LIMITED) or True # Just verify it doesn't crash
|
|
284
|
+
|
|
285
|
+
|
|
286
|
+
class TestContextManager:
|
|
287
|
+
"""Tests for context manager support"""
|
|
288
|
+
|
|
289
|
+
@patch('tollmeshcache.client.requests.Session')
|
|
290
|
+
def test_context_manager(self, mock_session):
|
|
291
|
+
"""Test client as context manager"""
|
|
292
|
+
with Client(ClientConfig()) as client:
|
|
293
|
+
assert client is not None
|
|
294
|
+
|
|
295
|
+
|
|
296
|
+
class TestConfiguration:
|
|
297
|
+
"""Tests for client configuration"""
|
|
298
|
+
|
|
299
|
+
def test_retry_configuration(self):
|
|
300
|
+
"""Test retry configuration"""
|
|
301
|
+
config = ClientConfig(max_retries=5, retry_backoff=2.0)
|
|
302
|
+
assert config.max_retries == 5
|
|
303
|
+
assert config.retry_backoff == 2.0
|
|
304
|
+
|
|
305
|
+
def test_connection_pool_configuration(self):
|
|
306
|
+
"""Test connection pool configuration"""
|
|
307
|
+
config = ClientConfig(connection_pool_size=20)
|
|
308
|
+
assert config.connection_pool_size == 20
|
|
309
|
+
|
|
310
|
+
|
|
311
|
+
if __name__ == "__main__":
|
|
312
|
+
pytest.main([__file__, "-v"])
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
"""
|
|
2
|
+
TollMeshCache Python SDK - Distributed CRDT-based caching and coordination
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
from .client import Client
|
|
6
|
+
from .async_client import AsyncClient
|
|
7
|
+
from .config import ClientConfig
|
|
8
|
+
from .errors import (
|
|
9
|
+
TollMeshError,
|
|
10
|
+
ErrorCode,
|
|
11
|
+
RateLimitError,
|
|
12
|
+
ReplayError,
|
|
13
|
+
CacheMissError,
|
|
14
|
+
RATE_LIMITED,
|
|
15
|
+
REPLAY_DETECTED,
|
|
16
|
+
CACHE_MISS,
|
|
17
|
+
INVALID_REQUEST,
|
|
18
|
+
INTERNAL_ERROR,
|
|
19
|
+
)
|
|
20
|
+
from .retry import RetryConfig, retry, RetryHelper
|
|
21
|
+
|
|
22
|
+
__version__ = "1.0.0"
|
|
23
|
+
__author__ = "TollMesh Team"
|
|
24
|
+
|
|
25
|
+
__all__ = [
|
|
26
|
+
# Client
|
|
27
|
+
"Client",
|
|
28
|
+
"AsyncClient",
|
|
29
|
+
"ClientConfig",
|
|
30
|
+
# Errors
|
|
31
|
+
"TollMeshError",
|
|
32
|
+
"ErrorCode",
|
|
33
|
+
"RateLimitError",
|
|
34
|
+
"ReplayError",
|
|
35
|
+
"CacheMissError",
|
|
36
|
+
"RATE_LIMITED",
|
|
37
|
+
"REPLAY_DETECTED",
|
|
38
|
+
"CACHE_MISS",
|
|
39
|
+
"INVALID_REQUEST",
|
|
40
|
+
"INTERNAL_ERROR",
|
|
41
|
+
# Retry
|
|
42
|
+
"RetryConfig",
|
|
43
|
+
"retry",
|
|
44
|
+
"RetryHelper",
|
|
45
|
+
]
|