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/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
+