cledar-sdk 2.0.2__py3-none-any.whl → 2.1.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.
- cledar/__init__.py +1 -0
- cledar/kafka/README.md +239 -0
- cledar/kafka/__init__.py +42 -0
- cledar/kafka/clients/base.py +117 -0
- cledar/kafka/clients/consumer.py +138 -0
- cledar/kafka/clients/producer.py +97 -0
- cledar/kafka/config/schemas.py +262 -0
- cledar/kafka/exceptions.py +17 -0
- cledar/kafka/handlers/dead_letter.py +88 -0
- cledar/kafka/handlers/parser.py +83 -0
- cledar/kafka/logger.py +5 -0
- cledar/kafka/models/input.py +17 -0
- cledar/kafka/models/message.py +14 -0
- cledar/kafka/models/output.py +12 -0
- cledar/kafka/tests/.env.test.kafka +3 -0
- cledar/kafka/tests/README.md +216 -0
- cledar/kafka/tests/conftest.py +104 -0
- cledar/kafka/tests/integration/__init__.py +1 -0
- cledar/kafka/tests/integration/conftest.py +78 -0
- cledar/kafka/tests/integration/helpers.py +47 -0
- cledar/kafka/tests/integration/test_consumer_integration.py +375 -0
- cledar/kafka/tests/integration/test_integration.py +394 -0
- cledar/kafka/tests/integration/test_producer_consumer_interaction.py +388 -0
- cledar/kafka/tests/integration/test_producer_integration.py +217 -0
- cledar/kafka/tests/unit/__init__.py +1 -0
- cledar/kafka/tests/unit/test_base_kafka_client.py +391 -0
- cledar/kafka/tests/unit/test_config_validation.py +609 -0
- cledar/kafka/tests/unit/test_dead_letter_handler.py +443 -0
- cledar/kafka/tests/unit/test_error_handling.py +674 -0
- cledar/kafka/tests/unit/test_input_parser.py +310 -0
- cledar/kafka/tests/unit/test_input_parser_comprehensive.py +489 -0
- cledar/kafka/tests/unit/test_utils.py +25 -0
- cledar/kafka/tests/unit/test_utils_comprehensive.py +408 -0
- cledar/kafka/utils/callbacks.py +28 -0
- cledar/kafka/utils/messages.py +39 -0
- cledar/kafka/utils/topics.py +15 -0
- cledar/kserve/README.md +352 -0
- cledar/kserve/__init__.py +5 -0
- cledar/kserve/tests/__init__.py +0 -0
- cledar/kserve/tests/test_utils.py +64 -0
- cledar/kserve/utils.py +30 -0
- cledar/logging/README.md +53 -0
- cledar/logging/__init__.py +5 -0
- cledar/logging/tests/test_universal_plaintext_formatter.py +249 -0
- cledar/logging/universal_plaintext_formatter.py +99 -0
- cledar/monitoring/README.md +71 -0
- cledar/monitoring/__init__.py +5 -0
- cledar/monitoring/monitoring_server.py +156 -0
- cledar/monitoring/tests/integration/test_monitoring_server_int.py +162 -0
- cledar/monitoring/tests/test_monitoring_server.py +59 -0
- cledar/nonce/README.md +99 -0
- cledar/nonce/__init__.py +5 -0
- cledar/nonce/nonce_service.py +62 -0
- cledar/nonce/tests/__init__.py +0 -0
- cledar/nonce/tests/test_nonce_service.py +136 -0
- cledar/redis/README.md +536 -0
- cledar/redis/__init__.py +17 -0
- cledar/redis/async_example.py +112 -0
- cledar/redis/example.py +67 -0
- cledar/redis/exceptions.py +25 -0
- cledar/redis/logger.py +5 -0
- cledar/redis/model.py +14 -0
- cledar/redis/redis.py +764 -0
- cledar/redis/redis_config_store.py +333 -0
- cledar/redis/tests/test_async_integration_redis.py +158 -0
- cledar/redis/tests/test_async_redis_service.py +380 -0
- cledar/redis/tests/test_integration_redis.py +119 -0
- cledar/redis/tests/test_redis_service.py +319 -0
- cledar/storage/README.md +529 -0
- cledar/storage/__init__.py +6 -0
- cledar/storage/constants.py +5 -0
- cledar/storage/exceptions.py +79 -0
- cledar/storage/models.py +41 -0
- cledar/storage/object_storage.py +1274 -0
- cledar/storage/tests/conftest.py +18 -0
- cledar/storage/tests/test_abfs.py +164 -0
- cledar/storage/tests/test_integration_filesystem.py +359 -0
- cledar/storage/tests/test_integration_s3.py +453 -0
- cledar/storage/tests/test_local.py +384 -0
- cledar/storage/tests/test_s3.py +521 -0
- {cledar_sdk-2.0.2.dist-info → cledar_sdk-2.1.0.dist-info}/METADATA +1 -1
- cledar_sdk-2.1.0.dist-info/RECORD +84 -0
- cledar_sdk-2.0.2.dist-info/RECORD +0 -4
- {cledar_sdk-2.0.2.dist-info → cledar_sdk-2.1.0.dist-info}/WHEEL +0 -0
- {cledar_sdk-2.0.2.dist-info → cledar_sdk-2.1.0.dist-info}/licenses/LICENSE +0 -0
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
# pylint: disable=redefined-outer-name, unreachable
|
|
2
|
+
import logging
|
|
3
|
+
from collections.abc import Awaitable, Callable
|
|
4
|
+
|
|
5
|
+
import pytest
|
|
6
|
+
from fastapi import FastAPI
|
|
7
|
+
from httpx import ASGITransport, AsyncClient
|
|
8
|
+
|
|
9
|
+
from cledar.monitoring import EndpointFilter, MonitoringServer, MonitoringServerConfig
|
|
10
|
+
|
|
11
|
+
# ------------------------------------------------------------------------------
|
|
12
|
+
# Fixtures
|
|
13
|
+
# ------------------------------------------------------------------------------
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
@pytest.fixture
|
|
17
|
+
def test_app() -> FastAPI:
|
|
18
|
+
"""Creates a FastAPI app with configured monitoring endpoints."""
|
|
19
|
+
app = FastAPI()
|
|
20
|
+
config = MonitoringServerConfig(
|
|
21
|
+
readiness_checks={"db": lambda: True},
|
|
22
|
+
liveness_checks={"heartbeat": lambda: True},
|
|
23
|
+
)
|
|
24
|
+
server = MonitoringServer("127.0.0.1", 8000, config)
|
|
25
|
+
server.add_paths(app)
|
|
26
|
+
return app
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
ASGIClientFactory = Callable[[FastAPI], Awaitable[AsyncClient]]
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
@pytest.fixture
|
|
33
|
+
def asgi_client_factory() -> ASGIClientFactory:
|
|
34
|
+
"""Factory to create an httpx.AsyncClient for ASGI apps (httpx>=0.28)."""
|
|
35
|
+
|
|
36
|
+
async def _make_client(app: FastAPI) -> AsyncClient:
|
|
37
|
+
transport = ASGITransport(app=app)
|
|
38
|
+
return AsyncClient(transport=transport, base_url="http://test")
|
|
39
|
+
|
|
40
|
+
return _make_client
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
# ------------------------------------------------------------------------------
|
|
44
|
+
# Readiness & Liveness Endpoints
|
|
45
|
+
# ------------------------------------------------------------------------------
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
@pytest.mark.asyncio
|
|
49
|
+
async def test_readiness_ok(
|
|
50
|
+
test_app: FastAPI, asgi_client_factory: ASGIClientFactory
|
|
51
|
+
) -> None:
|
|
52
|
+
"""Readiness endpoint returns OK when all checks pass."""
|
|
53
|
+
async with await asgi_client_factory(test_app) as client:
|
|
54
|
+
response = await client.get("/healthz/readiness")
|
|
55
|
+
|
|
56
|
+
data = response.json()
|
|
57
|
+
assert response.status_code == 200
|
|
58
|
+
assert data["status"] == "ok"
|
|
59
|
+
assert data["checks"]["db"] is True
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
@pytest.mark.asyncio
|
|
63
|
+
async def test_liveness_ok(
|
|
64
|
+
test_app: FastAPI, asgi_client_factory: ASGIClientFactory
|
|
65
|
+
) -> None:
|
|
66
|
+
"""Liveness endpoint returns OK when all checks pass."""
|
|
67
|
+
async with await asgi_client_factory(test_app) as client:
|
|
68
|
+
response = await client.get("/healthz/liveness")
|
|
69
|
+
|
|
70
|
+
data = response.json()
|
|
71
|
+
assert response.status_code == 200
|
|
72
|
+
assert data["status"] == "ok"
|
|
73
|
+
assert data["checks"]["heartbeat"] is True
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
@pytest.mark.asyncio
|
|
77
|
+
async def test_readiness_failing_check(asgi_client_factory: ASGIClientFactory) -> None:
|
|
78
|
+
"""Readiness endpoint returns 503 and error status if a check fails."""
|
|
79
|
+
app = FastAPI()
|
|
80
|
+
config = MonitoringServerConfig(readiness_checks={"db": lambda: False})
|
|
81
|
+
server = MonitoringServer("127.0.0.1", 8000, config)
|
|
82
|
+
server.add_paths(app)
|
|
83
|
+
|
|
84
|
+
async with await asgi_client_factory(app) as client:
|
|
85
|
+
response = await client.get("/healthz/readiness")
|
|
86
|
+
|
|
87
|
+
data = response.json()
|
|
88
|
+
assert response.status_code == 503
|
|
89
|
+
assert data["status"] == "error"
|
|
90
|
+
assert data["checks"]["db"] is False
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
@pytest.mark.asyncio
|
|
94
|
+
async def test_readiness_with_exception(asgi_client_factory: ASGIClientFactory) -> None:
|
|
95
|
+
"""Readiness returns 503 if a check raises an exception."""
|
|
96
|
+
|
|
97
|
+
def failing_check() -> bool:
|
|
98
|
+
raise RuntimeError("DB not reachable")
|
|
99
|
+
return False
|
|
100
|
+
|
|
101
|
+
app = FastAPI()
|
|
102
|
+
config = MonitoringServerConfig(readiness_checks={"db": failing_check})
|
|
103
|
+
server = MonitoringServer("127.0.0.1", 8000, config)
|
|
104
|
+
server.add_paths(app)
|
|
105
|
+
|
|
106
|
+
async with await asgi_client_factory(app) as client:
|
|
107
|
+
response = await client.get("/healthz/readiness")
|
|
108
|
+
|
|
109
|
+
data = response.json()
|
|
110
|
+
assert response.status_code == 503
|
|
111
|
+
assert data["status"] == "error"
|
|
112
|
+
assert "DB not reachable" in data["message"]
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
# ------------------------------------------------------------------------------
|
|
116
|
+
# Metrics Endpoint
|
|
117
|
+
# ------------------------------------------------------------------------------
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
@pytest.mark.asyncio
|
|
121
|
+
async def test_metrics_endpoint_returns_prometheus_format(
|
|
122
|
+
test_app: FastAPI, asgi_client_factory: ASGIClientFactory
|
|
123
|
+
) -> None:
|
|
124
|
+
"""Metrics endpoint should return valid Prometheus output."""
|
|
125
|
+
async with await asgi_client_factory(test_app) as client:
|
|
126
|
+
response = await client.get("/metrics")
|
|
127
|
+
|
|
128
|
+
assert response.status_code == 200
|
|
129
|
+
assert response.headers["content-type"].startswith("text/plain")
|
|
130
|
+
assert "python_info" in response.text # standard metric
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
# ------------------------------------------------------------------------------
|
|
134
|
+
# Logging Filter
|
|
135
|
+
# ------------------------------------------------------------------------------
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def test_endpoint_filter_excludes_health_paths() -> None:
|
|
139
|
+
"""EndpointFilter should exclude healthz paths from logs."""
|
|
140
|
+
filter_ = EndpointFilter(["/healthz/readiness", "/healthz/liveness"])
|
|
141
|
+
|
|
142
|
+
record_excluded = logging.LogRecord(
|
|
143
|
+
name="uvicorn.access",
|
|
144
|
+
level=logging.INFO,
|
|
145
|
+
pathname="",
|
|
146
|
+
lineno=0,
|
|
147
|
+
msg="GET /healthz/readiness 200 OK",
|
|
148
|
+
args=(),
|
|
149
|
+
exc_info=None,
|
|
150
|
+
)
|
|
151
|
+
assert filter_.filter(record_excluded) is False
|
|
152
|
+
|
|
153
|
+
record_included = logging.LogRecord(
|
|
154
|
+
name="uvicorn.access",
|
|
155
|
+
level=logging.INFO,
|
|
156
|
+
pathname="",
|
|
157
|
+
lineno=0,
|
|
158
|
+
msg="GET /metrics 200 OK",
|
|
159
|
+
args=(),
|
|
160
|
+
exc_info=None,
|
|
161
|
+
)
|
|
162
|
+
assert filter_.filter(record_included) is True
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import pytest
|
|
2
|
+
from fastapi import FastAPI
|
|
3
|
+
from fastapi.testclient import TestClient
|
|
4
|
+
|
|
5
|
+
from cledar.monitoring import MonitoringServer, MonitoringServerConfig
|
|
6
|
+
|
|
7
|
+
HOST = "localhost"
|
|
8
|
+
PORT = 9999
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class ReadinessFlag:
|
|
12
|
+
def __init__(self) -> None:
|
|
13
|
+
self.is_ready = False
|
|
14
|
+
|
|
15
|
+
def mark_ready(self) -> None:
|
|
16
|
+
self.is_ready = True
|
|
17
|
+
|
|
18
|
+
def check_if_ready(self) -> bool:
|
|
19
|
+
return self.is_ready
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
@pytest.fixture
|
|
23
|
+
def readiness_flag() -> ReadinessFlag:
|
|
24
|
+
_readiness_flag = ReadinessFlag()
|
|
25
|
+
return _readiness_flag
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
@pytest.fixture
|
|
29
|
+
def app(readiness_flag: ReadinessFlag) -> FastAPI:
|
|
30
|
+
_app = FastAPI()
|
|
31
|
+
default_readiness_checks = dict({"is_ready": readiness_flag.check_if_ready})
|
|
32
|
+
config = MonitoringServerConfig(default_readiness_checks)
|
|
33
|
+
monitoring_server = MonitoringServer(HOST, PORT, config)
|
|
34
|
+
monitoring_server.add_paths(_app)
|
|
35
|
+
return _app
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
@pytest.fixture
|
|
39
|
+
def client(app: FastAPI) -> TestClient:
|
|
40
|
+
_client = TestClient(app)
|
|
41
|
+
return _client
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def test_liveness(client: TestClient) -> None:
|
|
45
|
+
response = client.get("/healthz/liveness")
|
|
46
|
+
assert response.status_code == 200
|
|
47
|
+
assert response.text == '{"status": "ok", "checks": {}}'
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def test_readiness(client: TestClient, readiness_flag: ReadinessFlag) -> None:
|
|
51
|
+
response = client.get("/healthz/readiness")
|
|
52
|
+
assert response.status_code == 503
|
|
53
|
+
assert response.text == '{"status": "error", "checks": {"is_ready": false}}'
|
|
54
|
+
|
|
55
|
+
readiness_flag.mark_ready()
|
|
56
|
+
|
|
57
|
+
response = client.get("/healthz/readiness")
|
|
58
|
+
assert response.status_code == 200
|
|
59
|
+
assert response.text == '{"status": "ok", "checks": {"is_ready": true}}'
|
cledar/nonce/README.md
ADDED
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
# NonceService
|
|
2
|
+
|
|
3
|
+
Simple nonce service for preventing duplicate request processing using Redis with TTL-based automatic cleanup.
|
|
4
|
+
|
|
5
|
+
It is useful for implementing idempotency in APIs, background workers, and event processing pipelines where the same request/message may arrive more than once.
|
|
6
|
+
|
|
7
|
+
## Features
|
|
8
|
+
- Fast duplicate detection backed by Redis SET NX
|
|
9
|
+
- Per-endpoint scoping (the same nonce can be used across different endpoints)
|
|
10
|
+
- Automatic expiry via TTL (default 1 hour)
|
|
11
|
+
- Tiny API surface and easy integration
|
|
12
|
+
|
|
13
|
+
## Requirements
|
|
14
|
+
- Redis server accessible from your application
|
|
15
|
+
- Python dependencies:
|
|
16
|
+
- `redis` (redis-py)
|
|
17
|
+
- This repository/module (imports shown below)
|
|
18
|
+
|
|
19
|
+
## Installation
|
|
20
|
+
Make sure you have `redis` installed in your environment:
|
|
21
|
+
|
|
22
|
+
```bash
|
|
23
|
+
uv add redis
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
This module lives inside this repository. Import it directly from your code as shown in the examples below.
|
|
27
|
+
|
|
28
|
+
## Quickstart
|
|
29
|
+
|
|
30
|
+
```python
|
|
31
|
+
import asyncio
|
|
32
|
+
from cledar.redis.redis import RedisService, RedisServiceConfig
|
|
33
|
+
from cledar.nonce import NonceService
|
|
34
|
+
|
|
35
|
+
# 1) Create a Redis client
|
|
36
|
+
config = RedisServiceConfig(redis_host="localhost", redis_port=6379, redis_db=0)
|
|
37
|
+
redis_client = RedisService(config)
|
|
38
|
+
|
|
39
|
+
# 2) Create the NonceService
|
|
40
|
+
nonce_service = NonceService(redis_client)
|
|
41
|
+
|
|
42
|
+
# Optional: override default TTL (seconds)
|
|
43
|
+
nonce_service.default_ttl = 7200 # 2 hours
|
|
44
|
+
|
|
45
|
+
async def main():
|
|
46
|
+
nonce = "request-id-123"
|
|
47
|
+
endpoint = "/api/payment" # any string identifying the endpoint or operation
|
|
48
|
+
|
|
49
|
+
# First time: not a duplicate -> returns False
|
|
50
|
+
first = await nonce_service.is_duplicate(nonce, endpoint)
|
|
51
|
+
print(first) # False
|
|
52
|
+
|
|
53
|
+
# Second time (same nonce + same endpoint): duplicate -> returns True
|
|
54
|
+
second = await nonce_service.is_duplicate(nonce, endpoint)
|
|
55
|
+
print(second) # True
|
|
56
|
+
|
|
57
|
+
# Same nonce but a different endpoint is treated independently
|
|
58
|
+
third = await nonce_service.is_duplicate(nonce, "/api/other-endpoint")
|
|
59
|
+
print(third) # False
|
|
60
|
+
|
|
61
|
+
asyncio.run(main())
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
## How it works
|
|
65
|
+
Under the hood, `NonceService.is_duplicate(nonce, endpoint)` performs a Redis `SET` with the flags `NX` (only set if not exists) and `EX=<TTL>`. If Redis returns that the key was set, this is the first time the nonce is seen for that endpoint (not a duplicate -> returns `False`). If the key already exists, Redis returns `None`, which the service treats as a duplicate -> returns `True`.
|
|
66
|
+
|
|
67
|
+
- Redis key format: `nonce:{endpoint}:{nonce}`
|
|
68
|
+
- Default TTL: `3600` seconds (1 hour); can be changed via `nonce_service.default_ttl`
|
|
69
|
+
- Endpoint scoping: the same nonce value can be used across different endpoints without being considered a duplicate between them
|
|
70
|
+
|
|
71
|
+
## API
|
|
72
|
+
|
|
73
|
+
```python
|
|
74
|
+
from cledar.redis.redis import RedisService
|
|
75
|
+
|
|
76
|
+
class NonceService:
|
|
77
|
+
def __init__(self, redis_client: RedisService):
|
|
78
|
+
"""Simple service for managing nonces to prevent duplicate requests."""
|
|
79
|
+
...
|
|
80
|
+
|
|
81
|
+
async def is_duplicate(self, nonce: str, endpoint: str) -> bool:
|
|
82
|
+
"""Return True if (nonce, endpoint) was already used within TTL."""
|
|
83
|
+
...
|
|
84
|
+
```
|
|
85
|
+
|
|
86
|
+
### Errors
|
|
87
|
+
- `RuntimeError("Redis client is not initialized")` — raised when `redis_client._client` is `None`.
|
|
88
|
+
|
|
89
|
+
## Testing
|
|
90
|
+
This module includes unit tests. To run them:
|
|
91
|
+
|
|
92
|
+
```bash
|
|
93
|
+
uv run pytest nonce_service/tests -q
|
|
94
|
+
```
|
|
95
|
+
|
|
96
|
+
## Best practices
|
|
97
|
+
- Use a stable nonce that uniquely identifies an operation (e.g., request ID, message ID)
|
|
98
|
+
- Choose a TTL that matches the maximum time window in which duplicates must be rejected
|
|
99
|
+
- Scope by a meaningful `endpoint` string to separate distinct operations
|
cledar/nonce/__init__.py
ADDED
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
"""Simple nonce service for preventing duplicate request processing.
|
|
2
|
+
|
|
3
|
+
Uses Redis with TTL for automatic cleanup.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from cledar.redis.redis import RedisService
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class NonceService:
|
|
10
|
+
"""Simple service for managing nonces to prevent duplicate requests."""
|
|
11
|
+
|
|
12
|
+
def __init__(self, redis_client: RedisService):
|
|
13
|
+
"""Initialize the nonce service.
|
|
14
|
+
|
|
15
|
+
Args:
|
|
16
|
+
redis_client: The Redis client service used for storage.
|
|
17
|
+
|
|
18
|
+
"""
|
|
19
|
+
self.redis_client = redis_client
|
|
20
|
+
self.nonce_prefix = "nonce"
|
|
21
|
+
self.default_ttl = 3600 # 1 hour
|
|
22
|
+
|
|
23
|
+
def _get_nonce_key(self, nonce: str, endpoint: str) -> str:
|
|
24
|
+
"""Generate Redis key for nonce.
|
|
25
|
+
|
|
26
|
+
Args:
|
|
27
|
+
nonce: The unique identifier for the request.
|
|
28
|
+
endpoint: The endpoint the request is directed at.
|
|
29
|
+
|
|
30
|
+
Returns:
|
|
31
|
+
str: The formatted Redis key.
|
|
32
|
+
|
|
33
|
+
"""
|
|
34
|
+
return f"{self.nonce_prefix}:{endpoint}:{nonce}"
|
|
35
|
+
|
|
36
|
+
async def is_duplicate(self, nonce: str, endpoint: str) -> bool:
|
|
37
|
+
"""Check if nonce was already used (returns True if duplicate).
|
|
38
|
+
|
|
39
|
+
Args:
|
|
40
|
+
nonce: The unique identifier for the request.
|
|
41
|
+
endpoint: The endpoint the request is directed at.
|
|
42
|
+
|
|
43
|
+
Returns:
|
|
44
|
+
bool: True if the nonce is a duplicate, False otherwise.
|
|
45
|
+
|
|
46
|
+
Raises:
|
|
47
|
+
RuntimeError: If the Redis client is not initialized.
|
|
48
|
+
|
|
49
|
+
"""
|
|
50
|
+
nonce_key = self._get_nonce_key(nonce, endpoint)
|
|
51
|
+
|
|
52
|
+
if self.redis_client._client is None:
|
|
53
|
+
raise RuntimeError("Redis client is not initialized")
|
|
54
|
+
|
|
55
|
+
result = self.redis_client._client.set(
|
|
56
|
+
nonce_key,
|
|
57
|
+
"used",
|
|
58
|
+
nx=True,
|
|
59
|
+
ex=self.default_ttl,
|
|
60
|
+
)
|
|
61
|
+
|
|
62
|
+
return result is None
|
|
File without changes
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
# mypy: disable-error-code=no-untyped-def
|
|
2
|
+
from unittest.mock import MagicMock
|
|
3
|
+
|
|
4
|
+
import pytest
|
|
5
|
+
|
|
6
|
+
from cledar.nonce import NonceService
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
@pytest.fixture(name="redis_client")
|
|
10
|
+
def fixture_redis_client():
|
|
11
|
+
"""Mock Redis client"""
|
|
12
|
+
mock_redis = MagicMock()
|
|
13
|
+
mock_redis._client = MagicMock()
|
|
14
|
+
return mock_redis
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
@pytest.fixture(name="nonce_service")
|
|
18
|
+
def fixture_nonce_service(redis_client):
|
|
19
|
+
"""Create NonceService with a mocked Redis client"""
|
|
20
|
+
return NonceService(redis_client)
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def test_init(redis_client):
|
|
24
|
+
"""Test NonceService initialization"""
|
|
25
|
+
service = NonceService(redis_client)
|
|
26
|
+
|
|
27
|
+
assert service.redis_client == redis_client
|
|
28
|
+
assert service.nonce_prefix == "nonce"
|
|
29
|
+
assert service.default_ttl == 3600
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def test_get_nonce_key(nonce_service):
|
|
33
|
+
"""Test nonce key generation"""
|
|
34
|
+
nonce = "test-nonce-123"
|
|
35
|
+
endpoint = "/api/payment"
|
|
36
|
+
|
|
37
|
+
key = nonce_service._get_nonce_key(nonce, endpoint)
|
|
38
|
+
|
|
39
|
+
assert key == "nonce:/api/payment:test-nonce-123"
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
@pytest.mark.asyncio
|
|
43
|
+
async def test_is_duplicate_first_time(nonce_service):
|
|
44
|
+
"""Test that the first use of nonce returns False (not duplicate)"""
|
|
45
|
+
nonce = "unique-nonce-456"
|
|
46
|
+
endpoint = "/api/transaction"
|
|
47
|
+
|
|
48
|
+
# Mock Redis set to return True (key was set successfully)
|
|
49
|
+
nonce_service.redis_client._client.set.return_value = True
|
|
50
|
+
|
|
51
|
+
result = await nonce_service.is_duplicate(nonce, endpoint)
|
|
52
|
+
|
|
53
|
+
assert result is False
|
|
54
|
+
nonce_service.redis_client._client.set.assert_called_once_with(
|
|
55
|
+
"nonce:/api/transaction:unique-nonce-456",
|
|
56
|
+
"used",
|
|
57
|
+
nx=True,
|
|
58
|
+
ex=3600,
|
|
59
|
+
)
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
@pytest.mark.asyncio
|
|
63
|
+
async def test_is_duplicate_second_time(nonce_service):
|
|
64
|
+
"""Test that second use of same nonce returns True (duplicate)"""
|
|
65
|
+
nonce = "duplicate-nonce-789"
|
|
66
|
+
endpoint = "/api/refund"
|
|
67
|
+
|
|
68
|
+
# Mock Redis set to return None (key already exists)
|
|
69
|
+
nonce_service.redis_client._client.set.return_value = None
|
|
70
|
+
|
|
71
|
+
result = await nonce_service.is_duplicate(nonce, endpoint)
|
|
72
|
+
|
|
73
|
+
assert result is True
|
|
74
|
+
nonce_service.redis_client._client.set.assert_called_once_with(
|
|
75
|
+
"nonce:/api/refund:duplicate-nonce-789",
|
|
76
|
+
"used",
|
|
77
|
+
nx=True,
|
|
78
|
+
ex=3600,
|
|
79
|
+
)
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
@pytest.mark.asyncio
|
|
83
|
+
async def test_is_duplicate_redis_not_initialized(nonce_service):
|
|
84
|
+
"""Test that RuntimeError is raised when a Redis client is not initialized"""
|
|
85
|
+
nonce = "test-nonce"
|
|
86
|
+
endpoint = "/api/test"
|
|
87
|
+
|
|
88
|
+
# Simulate uninitialized Redis client
|
|
89
|
+
nonce_service.redis_client._client = None
|
|
90
|
+
|
|
91
|
+
with pytest.raises(RuntimeError, match="Redis client is not initialized"):
|
|
92
|
+
await nonce_service.is_duplicate(nonce, endpoint)
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
@pytest.mark.asyncio
|
|
96
|
+
async def test_is_duplicate_different_endpoints(nonce_service):
|
|
97
|
+
"""Test that the same nonce is independent across different endpoints"""
|
|
98
|
+
nonce = "same-nonce"
|
|
99
|
+
endpoint1 = "/api/endpoint1"
|
|
100
|
+
endpoint2 = "/api/endpoint2"
|
|
101
|
+
|
|
102
|
+
# Both calls should treat the nonce as new (not duplicate)
|
|
103
|
+
nonce_service.redis_client._client.set.return_value = True
|
|
104
|
+
|
|
105
|
+
result1 = await nonce_service.is_duplicate(nonce, endpoint1)
|
|
106
|
+
result2 = await nonce_service.is_duplicate(nonce, endpoint2)
|
|
107
|
+
|
|
108
|
+
assert result1 is False
|
|
109
|
+
assert result2 is False
|
|
110
|
+
|
|
111
|
+
# Verify both keys were set with different endpoint prefixes
|
|
112
|
+
assert nonce_service.redis_client._client.set.call_count == 2
|
|
113
|
+
calls = nonce_service.redis_client._client.set.call_args_list
|
|
114
|
+
assert calls[0][0][0] == "nonce:/api/endpoint1:same-nonce"
|
|
115
|
+
assert calls[1][0][0] == "nonce:/api/endpoint2:same-nonce"
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
@pytest.mark.asyncio
|
|
119
|
+
async def test_is_duplicate_custom_ttl(redis_client):
|
|
120
|
+
"""Test that custom TTL is used correctly"""
|
|
121
|
+
service = NonceService(redis_client)
|
|
122
|
+
service.default_ttl = 7200 # 2 hours
|
|
123
|
+
|
|
124
|
+
nonce = "test-nonce"
|
|
125
|
+
endpoint = "/api/test"
|
|
126
|
+
|
|
127
|
+
redis_client._client.set.return_value = True
|
|
128
|
+
|
|
129
|
+
await service.is_duplicate(nonce, endpoint)
|
|
130
|
+
|
|
131
|
+
redis_client._client.set.assert_called_once_with(
|
|
132
|
+
"nonce:/api/test:test-nonce",
|
|
133
|
+
"used",
|
|
134
|
+
nx=True,
|
|
135
|
+
ex=7200,
|
|
136
|
+
)
|