tsf-sh 1.0.1__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 +4 -0
- tests/test_client.py +745 -0
- tsf_sh/__init__.py +35 -0
- tsf_sh/client.py +342 -0
- tsf_sh/exceptions.py +62 -0
- tsf_sh/models.py +96 -0
- tsf_sh-1.0.1.dist-info/METADATA +241 -0
- tsf_sh-1.0.1.dist-info/RECORD +10 -0
- tsf_sh-1.0.1.dist-info/WHEEL +5 -0
- tsf_sh-1.0.1.dist-info/top_level.txt +2 -0
tests/test_client.py
ADDED
|
@@ -0,0 +1,745 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Тесты для tsf-sh библиотеки
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
import pytest
|
|
6
|
+
import httpx
|
|
7
|
+
from unittest.mock import AsyncMock, patch
|
|
8
|
+
from datetime import datetime
|
|
9
|
+
|
|
10
|
+
from tsf_sh import Client, Link, LinkStats, HealthStatus
|
|
11
|
+
from tsf_sh.exceptions import (
|
|
12
|
+
Error,
|
|
13
|
+
APIError,
|
|
14
|
+
ValidationError,
|
|
15
|
+
UnauthorizedError,
|
|
16
|
+
ForbiddenError,
|
|
17
|
+
NotFoundError,
|
|
18
|
+
ConflictError,
|
|
19
|
+
RateLimitError,
|
|
20
|
+
InternalServerError
|
|
21
|
+
)
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
@pytest.fixture
|
|
25
|
+
def api_key():
|
|
26
|
+
return "test-api-key"
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
@pytest.fixture
|
|
30
|
+
def client(api_key):
|
|
31
|
+
return Client(api_key=api_key, base_url="https://test.tsf.sh")
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
@pytest.fixture
|
|
35
|
+
def mock_response():
|
|
36
|
+
"""Создает мок ответа"""
|
|
37
|
+
def _create_response(status_code=200, json_data=None, headers=None):
|
|
38
|
+
response = AsyncMock(spec=httpx.Response)
|
|
39
|
+
response.status_code = status_code
|
|
40
|
+
response.headers = headers or {}
|
|
41
|
+
response.json = AsyncMock(return_value=json_data or {})
|
|
42
|
+
response.text = str(json_data) if json_data else ""
|
|
43
|
+
return response
|
|
44
|
+
return _create_response
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
class TestClientInitialization:
|
|
48
|
+
"""Тесты инициализации клиента"""
|
|
49
|
+
|
|
50
|
+
def test_client_init(self, api_key):
|
|
51
|
+
client = Client(api_key=api_key)
|
|
52
|
+
assert client.api_key == api_key
|
|
53
|
+
assert client.base_url == "https://tsf.sh"
|
|
54
|
+
assert client.timeout == 30.0
|
|
55
|
+
assert client._client is None
|
|
56
|
+
|
|
57
|
+
def test_client_init_custom_base_url(self, api_key):
|
|
58
|
+
client = Client(api_key=api_key, base_url="https://custom.com")
|
|
59
|
+
assert client.base_url == "https://custom.com"
|
|
60
|
+
|
|
61
|
+
def test_client_init_custom_timeout(self, api_key):
|
|
62
|
+
client = Client(api_key=api_key, timeout=60.0)
|
|
63
|
+
assert client.timeout == 60.0
|
|
64
|
+
|
|
65
|
+
@pytest.mark.asyncio
|
|
66
|
+
async def test_context_manager(self, client):
|
|
67
|
+
async with client as c:
|
|
68
|
+
assert c._client is not None
|
|
69
|
+
assert isinstance(c._client, httpx.AsyncClient)
|
|
70
|
+
assert c._client is None
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
class TestCreateLink:
|
|
74
|
+
"""Тесты создания ссылки"""
|
|
75
|
+
|
|
76
|
+
@pytest.mark.asyncio
|
|
77
|
+
async def test_create_link_success(self, client, mock_response):
|
|
78
|
+
response_data = {
|
|
79
|
+
"success": True,
|
|
80
|
+
"data": {
|
|
81
|
+
"code": "abc123",
|
|
82
|
+
"short_url": "https://tsf.sh/abc123",
|
|
83
|
+
"original_url": "https://example.com",
|
|
84
|
+
"ttl_seconds": 86400,
|
|
85
|
+
"expires_at": 1234567890,
|
|
86
|
+
"has_password": False
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
with patch.object(client, '_ensure_client', new_callable=AsyncMock):
|
|
91
|
+
client._client = AsyncMock()
|
|
92
|
+
client._client.post = AsyncMock(return_value=mock_response(200, response_data))
|
|
93
|
+
|
|
94
|
+
link = await client.create_link("https://example.com", ttl_hours=24)
|
|
95
|
+
|
|
96
|
+
assert isinstance(link, Link)
|
|
97
|
+
assert link.code == "abc123"
|
|
98
|
+
assert link.short_url == "https://tsf.sh/abc123"
|
|
99
|
+
assert link.original_url == "https://example.com"
|
|
100
|
+
assert link.ttl_seconds == 86400
|
|
101
|
+
assert link.has_password is False
|
|
102
|
+
client._client.post.assert_called_once()
|
|
103
|
+
|
|
104
|
+
@pytest.mark.asyncio
|
|
105
|
+
async def test_create_link_with_password(self, client, mock_response):
|
|
106
|
+
response_data = {
|
|
107
|
+
"success": True,
|
|
108
|
+
"data": {
|
|
109
|
+
"code": "def456",
|
|
110
|
+
"short_url": "https://tsf.sh/def456",
|
|
111
|
+
"original_url": "https://example.com",
|
|
112
|
+
"ttl_seconds": 43200,
|
|
113
|
+
"expires_at": 1234567890,
|
|
114
|
+
"has_password": True
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
with patch.object(client, '_ensure_client', new_callable=AsyncMock):
|
|
119
|
+
client._client = AsyncMock()
|
|
120
|
+
client._client.post = AsyncMock(return_value=mock_response(200, response_data))
|
|
121
|
+
|
|
122
|
+
link = await client.create_link(
|
|
123
|
+
"https://example.com",
|
|
124
|
+
ttl_hours=12,
|
|
125
|
+
password="secret123"
|
|
126
|
+
)
|
|
127
|
+
|
|
128
|
+
assert link.has_password is True
|
|
129
|
+
call_args = client._client.post.call_args
|
|
130
|
+
assert call_args[1]["json"]["password"] == "secret123"
|
|
131
|
+
assert call_args[1]["json"]["ttl_hours"] == 12
|
|
132
|
+
|
|
133
|
+
@pytest.mark.asyncio
|
|
134
|
+
async def test_create_link_validation_error(self, client, mock_response):
|
|
135
|
+
error_data = {
|
|
136
|
+
"success": False,
|
|
137
|
+
"error": {
|
|
138
|
+
"code": "VALIDATION_ERROR",
|
|
139
|
+
"message": "URL должен начинаться с http:// или https://"
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
with patch.object(client, '_ensure_client', new_callable=AsyncMock):
|
|
144
|
+
client._client = AsyncMock()
|
|
145
|
+
client._client.post = AsyncMock(return_value=mock_response(400, error_data))
|
|
146
|
+
|
|
147
|
+
with pytest.raises(ValidationError) as exc_info:
|
|
148
|
+
await client.create_link("invalid-url")
|
|
149
|
+
|
|
150
|
+
assert "URL должен начинаться" in exc_info.value.message
|
|
151
|
+
|
|
152
|
+
@pytest.mark.asyncio
|
|
153
|
+
async def test_create_link_unauthorized(self, client, mock_response):
|
|
154
|
+
error_data = {
|
|
155
|
+
"success": False,
|
|
156
|
+
"error": {
|
|
157
|
+
"code": "INVALID_API_KEY",
|
|
158
|
+
"message": "Неверный API ключ"
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
with patch.object(client, '_ensure_client', new_callable=AsyncMock):
|
|
163
|
+
client._client = AsyncMock()
|
|
164
|
+
client._client.post = AsyncMock(return_value=mock_response(401, error_data))
|
|
165
|
+
|
|
166
|
+
with pytest.raises(UnauthorizedError):
|
|
167
|
+
await client.create_link("https://example.com")
|
|
168
|
+
|
|
169
|
+
@pytest.mark.asyncio
|
|
170
|
+
async def test_create_link_forbidden(self, client, mock_response):
|
|
171
|
+
error_data = {
|
|
172
|
+
"success": False,
|
|
173
|
+
"error": {
|
|
174
|
+
"code": "PREMIUM_REQUIRED",
|
|
175
|
+
"message": "Требуется премиум"
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
with patch.object(client, '_ensure_client', new_callable=AsyncMock):
|
|
180
|
+
client._client = AsyncMock()
|
|
181
|
+
client._client.post = AsyncMock(return_value=mock_response(403, error_data))
|
|
182
|
+
|
|
183
|
+
with pytest.raises(ForbiddenError):
|
|
184
|
+
await client.create_link("https://example.com")
|
|
185
|
+
|
|
186
|
+
@pytest.mark.asyncio
|
|
187
|
+
async def test_create_link_conflict(self, client, mock_response):
|
|
188
|
+
error_data = {
|
|
189
|
+
"success": False,
|
|
190
|
+
"error": {
|
|
191
|
+
"code": "LINK_ALREADY_EXISTS",
|
|
192
|
+
"message": "Эта ссылка уже была сокращена",
|
|
193
|
+
"existing_code": "xyz789"
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
with patch.object(client, '_ensure_client', new_callable=AsyncMock):
|
|
198
|
+
client._client = AsyncMock()
|
|
199
|
+
client._client.post = AsyncMock(return_value=mock_response(409, error_data))
|
|
200
|
+
|
|
201
|
+
with pytest.raises(ConflictError) as exc_info:
|
|
202
|
+
await client.create_link("https://example.com")
|
|
203
|
+
|
|
204
|
+
assert exc_info.value.existing_code == "xyz789"
|
|
205
|
+
|
|
206
|
+
@pytest.mark.asyncio
|
|
207
|
+
async def test_create_link_rate_limit(self, client, mock_response):
|
|
208
|
+
error_data = {
|
|
209
|
+
"success": False,
|
|
210
|
+
"error": {
|
|
211
|
+
"code": "RATE_LIMIT_EXCEEDED",
|
|
212
|
+
"message": "Превышен лимит запросов"
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
headers = {"X-RateLimit-Reset": "1234567890"}
|
|
217
|
+
|
|
218
|
+
with patch.object(client, '_ensure_client', new_callable=AsyncMock):
|
|
219
|
+
client._client = AsyncMock()
|
|
220
|
+
client._client.post = AsyncMock(return_value=mock_response(429, error_data, headers))
|
|
221
|
+
|
|
222
|
+
with pytest.raises(RateLimitError) as exc_info:
|
|
223
|
+
await client.create_link("https://example.com")
|
|
224
|
+
|
|
225
|
+
assert exc_info.value.reset_time == 1234567890
|
|
226
|
+
|
|
227
|
+
|
|
228
|
+
class TestGetLinks:
|
|
229
|
+
"""Тесты получения списка ссылок"""
|
|
230
|
+
|
|
231
|
+
@pytest.mark.asyncio
|
|
232
|
+
async def test_get_links_success(self, client, mock_response):
|
|
233
|
+
response_data = {
|
|
234
|
+
"success": True,
|
|
235
|
+
"data": {
|
|
236
|
+
"links": [
|
|
237
|
+
{
|
|
238
|
+
"code": "abc123",
|
|
239
|
+
"short_url": "https://tsf.sh/abc123",
|
|
240
|
+
"original_url": "https://example.com",
|
|
241
|
+
"clicks": 10,
|
|
242
|
+
"ttl_seconds": 86400,
|
|
243
|
+
"created_at": 1234567890,
|
|
244
|
+
"expires_at": 1234654290,
|
|
245
|
+
"has_password": False
|
|
246
|
+
},
|
|
247
|
+
{
|
|
248
|
+
"code": "def456",
|
|
249
|
+
"short_url": "https://tsf.sh/def456",
|
|
250
|
+
"original_url": "https://test.com",
|
|
251
|
+
"clicks": 5,
|
|
252
|
+
"ttl_seconds": 43200,
|
|
253
|
+
"created_at": 1234500000,
|
|
254
|
+
"expires_at": 1234543200,
|
|
255
|
+
"has_password": True
|
|
256
|
+
}
|
|
257
|
+
]
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
with patch.object(client, '_ensure_client', new_callable=AsyncMock):
|
|
262
|
+
client._client = AsyncMock()
|
|
263
|
+
client._client.get = AsyncMock(return_value=mock_response(200, response_data))
|
|
264
|
+
|
|
265
|
+
links = await client.get_links()
|
|
266
|
+
|
|
267
|
+
assert len(links) == 2
|
|
268
|
+
assert all(isinstance(link, Link) for link in links)
|
|
269
|
+
assert links[0].code == "abc123"
|
|
270
|
+
assert links[1].code == "def456"
|
|
271
|
+
|
|
272
|
+
@pytest.mark.asyncio
|
|
273
|
+
async def test_get_links_empty(self, client, mock_response):
|
|
274
|
+
response_data = {
|
|
275
|
+
"success": True,
|
|
276
|
+
"data": {
|
|
277
|
+
"links": []
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
with patch.object(client, '_ensure_client', new_callable=AsyncMock):
|
|
282
|
+
client._client = AsyncMock()
|
|
283
|
+
client._client.get = AsyncMock(return_value=mock_response(200, response_data))
|
|
284
|
+
|
|
285
|
+
links = await client.get_links()
|
|
286
|
+
|
|
287
|
+
assert len(links) == 0
|
|
288
|
+
|
|
289
|
+
|
|
290
|
+
class TestGetLink:
|
|
291
|
+
"""Тесты получения информации о ссылке"""
|
|
292
|
+
|
|
293
|
+
@pytest.mark.asyncio
|
|
294
|
+
async def test_get_link_success(self, client, mock_response):
|
|
295
|
+
response_data = {
|
|
296
|
+
"success": True,
|
|
297
|
+
"data": {
|
|
298
|
+
"code": "abc123",
|
|
299
|
+
"short_url": "https://tsf.sh/abc123",
|
|
300
|
+
"original_url": "https://example.com",
|
|
301
|
+
"clicks": 42,
|
|
302
|
+
"ttl_seconds": 86400,
|
|
303
|
+
"created_at": 1234567890,
|
|
304
|
+
"expires_at": 1234654290,
|
|
305
|
+
"has_password": False
|
|
306
|
+
}
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
with patch.object(client, '_ensure_client', new_callable=AsyncMock):
|
|
310
|
+
client._client = AsyncMock()
|
|
311
|
+
client._client.get = AsyncMock(return_value=mock_response(200, response_data))
|
|
312
|
+
|
|
313
|
+
link = await client.get_link("abc123")
|
|
314
|
+
|
|
315
|
+
assert link.code == "abc123"
|
|
316
|
+
assert link.clicks == 42
|
|
317
|
+
assert link.original_url == "https://example.com"
|
|
318
|
+
|
|
319
|
+
@pytest.mark.asyncio
|
|
320
|
+
async def test_get_link_not_found(self, client, mock_response):
|
|
321
|
+
error_data = {
|
|
322
|
+
"success": False,
|
|
323
|
+
"error": {
|
|
324
|
+
"code": "LINK_NOT_FOUND",
|
|
325
|
+
"message": "Ссылка не найдена"
|
|
326
|
+
}
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
with patch.object(client, '_ensure_client', new_callable=AsyncMock):
|
|
330
|
+
client._client = AsyncMock()
|
|
331
|
+
client._client.get = AsyncMock(return_value=mock_response(404, error_data))
|
|
332
|
+
|
|
333
|
+
with pytest.raises(NotFoundError):
|
|
334
|
+
await client.get_link("nonexistent")
|
|
335
|
+
|
|
336
|
+
|
|
337
|
+
class TestDeleteLink:
|
|
338
|
+
"""Тесты удаления ссылки"""
|
|
339
|
+
|
|
340
|
+
@pytest.mark.asyncio
|
|
341
|
+
async def test_delete_link_success(self, client, mock_response):
|
|
342
|
+
response_data = {
|
|
343
|
+
"success": True,
|
|
344
|
+
"message": "Ссылка удалена"
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
with patch.object(client, '_ensure_client', new_callable=AsyncMock):
|
|
348
|
+
client._client = AsyncMock()
|
|
349
|
+
client._client.delete = AsyncMock(return_value=mock_response(200, response_data))
|
|
350
|
+
|
|
351
|
+
result = await client.delete_link("abc123")
|
|
352
|
+
|
|
353
|
+
assert result is True
|
|
354
|
+
client._client.delete.assert_called_once_with("/api/links/abc123")
|
|
355
|
+
|
|
356
|
+
@pytest.mark.asyncio
|
|
357
|
+
async def test_delete_link_not_found(self, client, mock_response):
|
|
358
|
+
error_data = {
|
|
359
|
+
"success": False,
|
|
360
|
+
"error": {
|
|
361
|
+
"code": "LINK_NOT_FOUND",
|
|
362
|
+
"message": "Ссылка не найдена"
|
|
363
|
+
}
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
with patch.object(client, '_ensure_client', new_callable=AsyncMock):
|
|
367
|
+
client._client = AsyncMock()
|
|
368
|
+
client._client.delete = AsyncMock(return_value=mock_response(404, error_data))
|
|
369
|
+
|
|
370
|
+
with pytest.raises(NotFoundError):
|
|
371
|
+
await client.delete_link("nonexistent")
|
|
372
|
+
|
|
373
|
+
|
|
374
|
+
class TestExtendLink:
|
|
375
|
+
"""Тесты продления времени жизни ссылки"""
|
|
376
|
+
|
|
377
|
+
@pytest.mark.asyncio
|
|
378
|
+
async def test_extend_link_success(self, client, mock_response):
|
|
379
|
+
extend_response = {
|
|
380
|
+
"success": True,
|
|
381
|
+
"data": {
|
|
382
|
+
"code": "abc123",
|
|
383
|
+
"ttl_seconds": 86400,
|
|
384
|
+
"expires_at": 1234654290
|
|
385
|
+
}
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
get_response = {
|
|
389
|
+
"success": True,
|
|
390
|
+
"data": {
|
|
391
|
+
"code": "abc123",
|
|
392
|
+
"short_url": "https://tsf.sh/abc123",
|
|
393
|
+
"original_url": "https://example.com",
|
|
394
|
+
"clicks": 10,
|
|
395
|
+
"ttl_seconds": 86400,
|
|
396
|
+
"created_at": 1234567890,
|
|
397
|
+
"expires_at": 1234654290,
|
|
398
|
+
"has_password": False
|
|
399
|
+
}
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
with patch.object(client, '_ensure_client', new_callable=AsyncMock):
|
|
403
|
+
client._client = AsyncMock()
|
|
404
|
+
client._client.patch = AsyncMock(return_value=mock_response(200, extend_response))
|
|
405
|
+
client._client.get = AsyncMock(return_value=mock_response(200, get_response))
|
|
406
|
+
|
|
407
|
+
link = await client.extend_link("abc123", ttl_hours=24)
|
|
408
|
+
|
|
409
|
+
assert isinstance(link, Link)
|
|
410
|
+
client._client.patch.assert_called_once()
|
|
411
|
+
call_args = client._client.patch.call_args
|
|
412
|
+
assert call_args[1]["json"]["ttl_hours"] == 24
|
|
413
|
+
|
|
414
|
+
|
|
415
|
+
class TestPasswordOperations:
|
|
416
|
+
"""Тесты операций с паролем"""
|
|
417
|
+
|
|
418
|
+
@pytest.mark.asyncio
|
|
419
|
+
async def test_set_password_success(self, client, mock_response):
|
|
420
|
+
response_data = {
|
|
421
|
+
"success": True,
|
|
422
|
+
"data": {
|
|
423
|
+
"message": "Пароль установлен"
|
|
424
|
+
}
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
with patch.object(client, '_ensure_client', new_callable=AsyncMock):
|
|
428
|
+
client._client = AsyncMock()
|
|
429
|
+
client._client.post = AsyncMock(return_value=mock_response(200, response_data))
|
|
430
|
+
|
|
431
|
+
result = await client.set_password("abc123", "newpassword")
|
|
432
|
+
|
|
433
|
+
assert result is True
|
|
434
|
+
call_args = client._client.post.call_args
|
|
435
|
+
assert call_args[0][0] == "/api/links/abc123/password"
|
|
436
|
+
assert call_args[1]["json"]["password"] == "newpassword"
|
|
437
|
+
|
|
438
|
+
@pytest.mark.asyncio
|
|
439
|
+
async def test_remove_password_success(self, client, mock_response):
|
|
440
|
+
response_data = {
|
|
441
|
+
"success": True,
|
|
442
|
+
"data": {
|
|
443
|
+
"message": "Пароль удален"
|
|
444
|
+
}
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
with patch.object(client, '_ensure_client', new_callable=AsyncMock):
|
|
448
|
+
client._client = AsyncMock()
|
|
449
|
+
client._client.delete = AsyncMock(return_value=mock_response(200, response_data))
|
|
450
|
+
|
|
451
|
+
result = await client.remove_password("abc123")
|
|
452
|
+
|
|
453
|
+
assert result is True
|
|
454
|
+
client._client.delete.assert_called_once_with("/api/links/abc123/password")
|
|
455
|
+
|
|
456
|
+
|
|
457
|
+
class TestRerollCode:
|
|
458
|
+
"""Тесты перегенерации кода"""
|
|
459
|
+
|
|
460
|
+
@pytest.mark.asyncio
|
|
461
|
+
async def test_reroll_code_success(self, client, mock_response):
|
|
462
|
+
response_data = {
|
|
463
|
+
"success": True,
|
|
464
|
+
"data": {
|
|
465
|
+
"old_code": "abc123",
|
|
466
|
+
"new_code": "xyz789",
|
|
467
|
+
"short_url": "https://tsf.sh/xyz789"
|
|
468
|
+
}
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
with patch.object(client, '_ensure_client', new_callable=AsyncMock):
|
|
472
|
+
client._client = AsyncMock()
|
|
473
|
+
client._client.post = AsyncMock(return_value=mock_response(200, response_data))
|
|
474
|
+
|
|
475
|
+
new_code = await client.reroll_code("abc123")
|
|
476
|
+
|
|
477
|
+
assert new_code == "xyz789"
|
|
478
|
+
client._client.post.assert_called_once_with("/api/links/abc123/reroll")
|
|
479
|
+
|
|
480
|
+
|
|
481
|
+
class TestGetStats:
|
|
482
|
+
"""Тесты получения статистики"""
|
|
483
|
+
|
|
484
|
+
@pytest.mark.asyncio
|
|
485
|
+
async def test_get_stats_success(self, client, mock_response):
|
|
486
|
+
response_data = {
|
|
487
|
+
"success": True,
|
|
488
|
+
"data": {
|
|
489
|
+
"links_count": 5,
|
|
490
|
+
"total_clicks": 150
|
|
491
|
+
}
|
|
492
|
+
}
|
|
493
|
+
|
|
494
|
+
with patch.object(client, '_ensure_client', new_callable=AsyncMock):
|
|
495
|
+
client._client = AsyncMock()
|
|
496
|
+
client._client.get = AsyncMock(return_value=mock_response(200, response_data))
|
|
497
|
+
|
|
498
|
+
stats = await client.get_stats()
|
|
499
|
+
|
|
500
|
+
assert isinstance(stats, LinkStats)
|
|
501
|
+
assert stats.links_count == 5
|
|
502
|
+
assert stats.total_clicks == 150
|
|
503
|
+
|
|
504
|
+
|
|
505
|
+
class TestHealthCheck:
|
|
506
|
+
"""Тесты проверки здоровья API"""
|
|
507
|
+
|
|
508
|
+
@pytest.mark.asyncio
|
|
509
|
+
async def test_health_check_success(self, client, mock_response):
|
|
510
|
+
response_data = {
|
|
511
|
+
"success": True,
|
|
512
|
+
"status": "healthy",
|
|
513
|
+
"services": {
|
|
514
|
+
"redis": "ok",
|
|
515
|
+
"api": "ok"
|
|
516
|
+
},
|
|
517
|
+
"timestamp": 1234567890
|
|
518
|
+
}
|
|
519
|
+
|
|
520
|
+
with patch.object(client, '_ensure_client', new_callable=AsyncMock):
|
|
521
|
+
client._client = AsyncMock()
|
|
522
|
+
client._client.get = AsyncMock(return_value=mock_response(200, response_data))
|
|
523
|
+
|
|
524
|
+
health = await client.health_check()
|
|
525
|
+
|
|
526
|
+
assert isinstance(health, HealthStatus)
|
|
527
|
+
assert health.status == "healthy"
|
|
528
|
+
assert health.is_healthy is True
|
|
529
|
+
assert health.services["redis"] == "ok"
|
|
530
|
+
|
|
531
|
+
@pytest.mark.asyncio
|
|
532
|
+
async def test_health_check_degraded(self, client, mock_response):
|
|
533
|
+
response_data = {
|
|
534
|
+
"success": True,
|
|
535
|
+
"status": "degraded",
|
|
536
|
+
"services": {
|
|
537
|
+
"redis": "error",
|
|
538
|
+
"api": "ok"
|
|
539
|
+
},
|
|
540
|
+
"timestamp": 1234567890
|
|
541
|
+
}
|
|
542
|
+
|
|
543
|
+
with patch.object(client, '_ensure_client', new_callable=AsyncMock):
|
|
544
|
+
client._client = AsyncMock()
|
|
545
|
+
client._client.get = AsyncMock(return_value=mock_response(200, response_data))
|
|
546
|
+
|
|
547
|
+
health = await client.health_check()
|
|
548
|
+
|
|
549
|
+
assert health.status == "degraded"
|
|
550
|
+
assert health.is_healthy is False
|
|
551
|
+
|
|
552
|
+
|
|
553
|
+
class TestErrorHandling:
|
|
554
|
+
"""Тесты обработки ошибок"""
|
|
555
|
+
|
|
556
|
+
@pytest.mark.asyncio
|
|
557
|
+
async def test_invalid_json_response(self, client, mock_response):
|
|
558
|
+
response = mock_response(200, None)
|
|
559
|
+
response.json = AsyncMock(side_effect=ValueError("Invalid JSON"))
|
|
560
|
+
response.text = "Not JSON"
|
|
561
|
+
|
|
562
|
+
with patch.object(client, '_ensure_client', new_callable=AsyncMock):
|
|
563
|
+
client._client = AsyncMock()
|
|
564
|
+
client._client.get = AsyncMock(return_value=response)
|
|
565
|
+
|
|
566
|
+
with pytest.raises(APIError) as exc_info:
|
|
567
|
+
await client.get_links()
|
|
568
|
+
|
|
569
|
+
assert "Неверный формат ответа" in exc_info.value.message
|
|
570
|
+
|
|
571
|
+
@pytest.mark.asyncio
|
|
572
|
+
async def test_internal_server_error(self, client, mock_response):
|
|
573
|
+
error_data = {
|
|
574
|
+
"success": False,
|
|
575
|
+
"error": {
|
|
576
|
+
"code": "INTERNAL_SERVER_ERROR",
|
|
577
|
+
"message": "Внутренняя ошибка сервера"
|
|
578
|
+
}
|
|
579
|
+
}
|
|
580
|
+
|
|
581
|
+
with patch.object(client, '_ensure_client', new_callable=AsyncMock):
|
|
582
|
+
client._client = AsyncMock()
|
|
583
|
+
client._client.get = AsyncMock(return_value=mock_response(500, error_data))
|
|
584
|
+
|
|
585
|
+
with pytest.raises(InternalServerError):
|
|
586
|
+
await client.get_links()
|
|
587
|
+
|
|
588
|
+
@pytest.mark.asyncio
|
|
589
|
+
async def test_unknown_error_code(self, client, mock_response):
|
|
590
|
+
error_data = {
|
|
591
|
+
"success": False,
|
|
592
|
+
"error": {
|
|
593
|
+
"code": "UNKNOWN_ERROR",
|
|
594
|
+
"message": "Неизвестная ошибка"
|
|
595
|
+
}
|
|
596
|
+
}
|
|
597
|
+
|
|
598
|
+
with patch.object(client, '_ensure_client', new_callable=AsyncMock):
|
|
599
|
+
client._client = AsyncMock()
|
|
600
|
+
client._client.get = AsyncMock(return_value=mock_response(418, error_data))
|
|
601
|
+
|
|
602
|
+
with pytest.raises(APIError) as exc_info:
|
|
603
|
+
await client.get_links()
|
|
604
|
+
|
|
605
|
+
assert exc_info.value.status_code == 418
|
|
606
|
+
assert exc_info.value.code == "UNKNOWN_ERROR"
|
|
607
|
+
|
|
608
|
+
|
|
609
|
+
class TestLinkModel:
|
|
610
|
+
"""Тесты модели Link"""
|
|
611
|
+
|
|
612
|
+
def test_link_from_dict(self):
|
|
613
|
+
data = {
|
|
614
|
+
"code": "abc123",
|
|
615
|
+
"short_url": "https://tsf.sh/abc123",
|
|
616
|
+
"original_url": "https://example.com",
|
|
617
|
+
"clicks": 10,
|
|
618
|
+
"ttl_seconds": 86400,
|
|
619
|
+
"created_at": 1234567890,
|
|
620
|
+
"expires_at": 1234654290,
|
|
621
|
+
"has_password": False
|
|
622
|
+
}
|
|
623
|
+
|
|
624
|
+
link = Link.from_dict(data)
|
|
625
|
+
|
|
626
|
+
assert link.code == "abc123"
|
|
627
|
+
assert link.clicks == 10
|
|
628
|
+
assert link.has_password is False
|
|
629
|
+
|
|
630
|
+
def test_link_datetime_properties(self):
|
|
631
|
+
data = {
|
|
632
|
+
"code": "abc123",
|
|
633
|
+
"short_url": "https://tsf.sh/abc123",
|
|
634
|
+
"original_url": "https://example.com",
|
|
635
|
+
"clicks": 0,
|
|
636
|
+
"ttl_seconds": 86400,
|
|
637
|
+
"created_at": 1234567890,
|
|
638
|
+
"expires_at": 1234654290,
|
|
639
|
+
"has_password": False
|
|
640
|
+
}
|
|
641
|
+
|
|
642
|
+
link = Link.from_dict(data)
|
|
643
|
+
|
|
644
|
+
assert isinstance(link.created_datetime, datetime)
|
|
645
|
+
assert isinstance(link.expires_datetime, datetime)
|
|
646
|
+
|
|
647
|
+
def test_link_is_expired(self):
|
|
648
|
+
import time
|
|
649
|
+
current_time = int(time.time())
|
|
650
|
+
|
|
651
|
+
expired_data = {
|
|
652
|
+
"code": "abc123",
|
|
653
|
+
"short_url": "https://tsf.sh/abc123",
|
|
654
|
+
"original_url": "https://example.com",
|
|
655
|
+
"clicks": 0,
|
|
656
|
+
"ttl_seconds": 86400,
|
|
657
|
+
"created_at": current_time - 100000,
|
|
658
|
+
"expires_at": current_time - 1000,
|
|
659
|
+
"has_password": False
|
|
660
|
+
}
|
|
661
|
+
|
|
662
|
+
link = Link.from_dict(expired_data)
|
|
663
|
+
assert link.is_expired is True
|
|
664
|
+
|
|
665
|
+
active_data = {
|
|
666
|
+
"code": "abc123",
|
|
667
|
+
"short_url": "https://tsf.sh/abc123",
|
|
668
|
+
"original_url": "https://example.com",
|
|
669
|
+
"clicks": 0,
|
|
670
|
+
"ttl_seconds": 86400,
|
|
671
|
+
"created_at": current_time,
|
|
672
|
+
"expires_at": current_time + 86400,
|
|
673
|
+
"has_password": False
|
|
674
|
+
}
|
|
675
|
+
|
|
676
|
+
link = Link.from_dict(active_data)
|
|
677
|
+
assert link.is_expired is False
|
|
678
|
+
|
|
679
|
+
def test_link_remaining_seconds(self):
|
|
680
|
+
import time
|
|
681
|
+
current_time = int(time.time())
|
|
682
|
+
|
|
683
|
+
data = {
|
|
684
|
+
"code": "abc123",
|
|
685
|
+
"short_url": "https://tsf.sh/abc123",
|
|
686
|
+
"original_url": "https://example.com",
|
|
687
|
+
"clicks": 0,
|
|
688
|
+
"ttl_seconds": 86400,
|
|
689
|
+
"created_at": current_time,
|
|
690
|
+
"expires_at": current_time + 3600,
|
|
691
|
+
"has_password": False
|
|
692
|
+
}
|
|
693
|
+
|
|
694
|
+
link = Link.from_dict(data)
|
|
695
|
+
remaining = link.remaining_seconds
|
|
696
|
+
|
|
697
|
+
assert 0 < remaining <= 3600
|
|
698
|
+
|
|
699
|
+
|
|
700
|
+
class TestLinkStatsModel:
|
|
701
|
+
"""Тесты модели LinkStats"""
|
|
702
|
+
|
|
703
|
+
def test_link_stats(self):
|
|
704
|
+
stats = LinkStats(links_count=5, total_clicks=150)
|
|
705
|
+
|
|
706
|
+
assert stats.links_count == 5
|
|
707
|
+
assert stats.total_clicks == 150
|
|
708
|
+
|
|
709
|
+
|
|
710
|
+
class TestHealthStatusModel:
|
|
711
|
+
"""Тесты модели HealthStatus"""
|
|
712
|
+
|
|
713
|
+
def test_health_status_from_dict(self):
|
|
714
|
+
data = {
|
|
715
|
+
"success": True,
|
|
716
|
+
"status": "healthy",
|
|
717
|
+
"services": {
|
|
718
|
+
"redis": "ok",
|
|
719
|
+
"api": "ok"
|
|
720
|
+
},
|
|
721
|
+
"timestamp": 1234567890
|
|
722
|
+
}
|
|
723
|
+
|
|
724
|
+
health = HealthStatus.from_dict(data)
|
|
725
|
+
|
|
726
|
+
assert health.status == "healthy"
|
|
727
|
+
assert health.is_healthy is True
|
|
728
|
+
assert health.services["redis"] == "ok"
|
|
729
|
+
|
|
730
|
+
def test_health_status_degraded(self):
|
|
731
|
+
data = {
|
|
732
|
+
"success": True,
|
|
733
|
+
"status": "degraded",
|
|
734
|
+
"services": {
|
|
735
|
+
"redis": "error",
|
|
736
|
+
"api": "ok"
|
|
737
|
+
},
|
|
738
|
+
"timestamp": 1234567890
|
|
739
|
+
}
|
|
740
|
+
|
|
741
|
+
health = HealthStatus.from_dict(data)
|
|
742
|
+
|
|
743
|
+
assert health.status == "degraded"
|
|
744
|
+
assert health.is_healthy is False
|
|
745
|
+
|