core-framework 0.12.6__py3-none-any.whl → 0.12.8__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.
- core/__init__.py +1 -1
- core/auth/decorators.py +28 -7
- core/cli/__init__.py +2 -0
- core/cli/main.py +163 -0
- core/permissions.py +29 -8
- core/testing/__init__.py +99 -0
- core/testing/assertions.py +347 -0
- core/testing/client.py +247 -0
- core/testing/database.py +307 -0
- core/testing/factories.py +393 -0
- core/testing/mocks.py +658 -0
- core/testing/plugin.py +635 -0
- {core_framework-0.12.6.dist-info → core_framework-0.12.8.dist-info}/METADATA +6 -1
- {core_framework-0.12.6.dist-info → core_framework-0.12.8.dist-info}/RECORD +16 -9
- {core_framework-0.12.6.dist-info → core_framework-0.12.8.dist-info}/entry_points.txt +3 -0
- {core_framework-0.12.6.dist-info → core_framework-0.12.8.dist-info}/WHEEL +0 -0
core/testing/mocks.py
ADDED
|
@@ -0,0 +1,658 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Mock implementations for external services.
|
|
3
|
+
|
|
4
|
+
Provides mock objects for Kafka, Redis, HTTP clients, and other external
|
|
5
|
+
services to enable isolated unit testing.
|
|
6
|
+
|
|
7
|
+
Usage:
|
|
8
|
+
# Kafka mock
|
|
9
|
+
kafka = MockKafka()
|
|
10
|
+
await kafka.send("events", {"type": "user.created"})
|
|
11
|
+
kafka.assert_sent("events", count=1)
|
|
12
|
+
|
|
13
|
+
# Redis mock
|
|
14
|
+
redis = MockRedis()
|
|
15
|
+
await redis.set("key", "value")
|
|
16
|
+
assert await redis.get("key") == "value"
|
|
17
|
+
|
|
18
|
+
# HTTP mock
|
|
19
|
+
http = MockHTTP()
|
|
20
|
+
http.when("GET", "https://api.example.com/users/1").respond(
|
|
21
|
+
status=200,
|
|
22
|
+
json={"id": 1, "name": "John"}
|
|
23
|
+
)
|
|
24
|
+
"""
|
|
25
|
+
|
|
26
|
+
from __future__ import annotations
|
|
27
|
+
|
|
28
|
+
import logging
|
|
29
|
+
from dataclasses import dataclass, field
|
|
30
|
+
from typing import Any, Callable
|
|
31
|
+
from datetime import datetime, timedelta
|
|
32
|
+
|
|
33
|
+
logger = logging.getLogger("core.testing")
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
# =============================================================================
|
|
37
|
+
# Kafka Mocks
|
|
38
|
+
# =============================================================================
|
|
39
|
+
|
|
40
|
+
@dataclass
|
|
41
|
+
class MockMessage:
|
|
42
|
+
"""
|
|
43
|
+
Mock Kafka message.
|
|
44
|
+
|
|
45
|
+
Attributes:
|
|
46
|
+
topic: Topic the message was sent to
|
|
47
|
+
value: Message payload
|
|
48
|
+
key: Optional message key
|
|
49
|
+
headers: Optional message headers
|
|
50
|
+
timestamp: When the message was sent
|
|
51
|
+
"""
|
|
52
|
+
topic: str
|
|
53
|
+
value: dict[str, Any]
|
|
54
|
+
key: str | None = None
|
|
55
|
+
headers: dict[str, str] | None = None
|
|
56
|
+
timestamp: datetime = field(default_factory=datetime.now)
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
@dataclass
|
|
60
|
+
class MockKafka:
|
|
61
|
+
"""
|
|
62
|
+
Mock Kafka producer/consumer.
|
|
63
|
+
|
|
64
|
+
Records all sent messages for assertions in tests.
|
|
65
|
+
|
|
66
|
+
Example:
|
|
67
|
+
kafka = MockKafka()
|
|
68
|
+
|
|
69
|
+
# Send messages
|
|
70
|
+
await kafka.send("events", {"type": "user.created", "user_id": 1})
|
|
71
|
+
await kafka.send("events", {"type": "user.updated", "user_id": 1})
|
|
72
|
+
await kafka.send("notifications", {"message": "Hello"})
|
|
73
|
+
|
|
74
|
+
# Assert
|
|
75
|
+
kafka.assert_sent("events", count=2)
|
|
76
|
+
kafka.assert_sent("notifications", count=1)
|
|
77
|
+
|
|
78
|
+
# Check specific message
|
|
79
|
+
assert kafka.messages[0].value["type"] == "user.created"
|
|
80
|
+
|
|
81
|
+
# Get messages by topic
|
|
82
|
+
event_messages = kafka.get_messages("events")
|
|
83
|
+
assert len(event_messages) == 2
|
|
84
|
+
"""
|
|
85
|
+
|
|
86
|
+
messages: list[MockMessage] = field(default_factory=list)
|
|
87
|
+
_consumers: dict[str, list[Callable]] = field(default_factory=dict)
|
|
88
|
+
|
|
89
|
+
async def send(
|
|
90
|
+
self,
|
|
91
|
+
topic: str,
|
|
92
|
+
value: dict[str, Any],
|
|
93
|
+
key: str | None = None,
|
|
94
|
+
headers: dict[str, str] | None = None,
|
|
95
|
+
) -> None:
|
|
96
|
+
"""
|
|
97
|
+
Record a sent message.
|
|
98
|
+
|
|
99
|
+
Args:
|
|
100
|
+
topic: Topic to send to
|
|
101
|
+
value: Message payload
|
|
102
|
+
key: Optional message key
|
|
103
|
+
headers: Optional headers
|
|
104
|
+
"""
|
|
105
|
+
message = MockMessage(
|
|
106
|
+
topic=topic,
|
|
107
|
+
value=value,
|
|
108
|
+
key=key,
|
|
109
|
+
headers=headers,
|
|
110
|
+
)
|
|
111
|
+
self.messages.append(message)
|
|
112
|
+
logger.debug(f"MockKafka: sent to {topic}: {value}")
|
|
113
|
+
|
|
114
|
+
# Notify consumers
|
|
115
|
+
for consumer in self._consumers.get(topic, []):
|
|
116
|
+
await consumer(message)
|
|
117
|
+
|
|
118
|
+
async def send_batch(
|
|
119
|
+
self,
|
|
120
|
+
topic: str,
|
|
121
|
+
messages: list[dict[str, Any]],
|
|
122
|
+
) -> None:
|
|
123
|
+
"""Send multiple messages to a topic."""
|
|
124
|
+
for msg in messages:
|
|
125
|
+
await self.send(topic, msg)
|
|
126
|
+
|
|
127
|
+
def subscribe(self, topic: str, callback: Callable) -> None:
|
|
128
|
+
"""Subscribe to a topic."""
|
|
129
|
+
if topic not in self._consumers:
|
|
130
|
+
self._consumers[topic] = []
|
|
131
|
+
self._consumers[topic].append(callback)
|
|
132
|
+
|
|
133
|
+
def get_messages(self, topic: str) -> list[MockMessage]:
|
|
134
|
+
"""Get all messages sent to a topic."""
|
|
135
|
+
return [m for m in self.messages if m.topic == topic]
|
|
136
|
+
|
|
137
|
+
def assert_sent(self, topic: str, count: int = 1) -> None:
|
|
138
|
+
"""
|
|
139
|
+
Assert that messages were sent to a topic.
|
|
140
|
+
|
|
141
|
+
Args:
|
|
142
|
+
topic: Topic to check
|
|
143
|
+
count: Expected number of messages
|
|
144
|
+
|
|
145
|
+
Raises:
|
|
146
|
+
AssertionError: If count doesn't match
|
|
147
|
+
"""
|
|
148
|
+
sent = self.get_messages(topic)
|
|
149
|
+
assert len(sent) == count, (
|
|
150
|
+
f"Expected {count} messages to '{topic}', got {len(sent)}. "
|
|
151
|
+
f"Messages: {[m.value for m in sent]}"
|
|
152
|
+
)
|
|
153
|
+
|
|
154
|
+
def assert_sent_with(
|
|
155
|
+
self,
|
|
156
|
+
topic: str,
|
|
157
|
+
**expected_fields,
|
|
158
|
+
) -> None:
|
|
159
|
+
"""
|
|
160
|
+
Assert that a message with specific fields was sent.
|
|
161
|
+
|
|
162
|
+
Args:
|
|
163
|
+
topic: Topic to check
|
|
164
|
+
**expected_fields: Fields that must be in at least one message
|
|
165
|
+
|
|
166
|
+
Raises:
|
|
167
|
+
AssertionError: If no matching message found
|
|
168
|
+
"""
|
|
169
|
+
sent = self.get_messages(topic)
|
|
170
|
+
for msg in sent:
|
|
171
|
+
if all(msg.value.get(k) == v for k, v in expected_fields.items()):
|
|
172
|
+
return
|
|
173
|
+
|
|
174
|
+
raise AssertionError(
|
|
175
|
+
f"No message to '{topic}' with fields {expected_fields}. "
|
|
176
|
+
f"Messages: {[m.value for m in sent]}"
|
|
177
|
+
)
|
|
178
|
+
|
|
179
|
+
def assert_not_sent(self, topic: str) -> None:
|
|
180
|
+
"""Assert that no messages were sent to a topic."""
|
|
181
|
+
sent = self.get_messages(topic)
|
|
182
|
+
assert len(sent) == 0, (
|
|
183
|
+
f"Expected no messages to '{topic}', got {len(sent)}. "
|
|
184
|
+
f"Messages: {[m.value for m in sent]}"
|
|
185
|
+
)
|
|
186
|
+
|
|
187
|
+
def clear(self) -> None:
|
|
188
|
+
"""Clear all recorded messages."""
|
|
189
|
+
self.messages.clear()
|
|
190
|
+
logger.debug("MockKafka: cleared messages")
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
# =============================================================================
|
|
194
|
+
# Redis Mocks
|
|
195
|
+
# =============================================================================
|
|
196
|
+
|
|
197
|
+
@dataclass
|
|
198
|
+
class MockRedis:
|
|
199
|
+
"""
|
|
200
|
+
Mock Redis client.
|
|
201
|
+
|
|
202
|
+
Simulates Redis operations in memory for testing.
|
|
203
|
+
|
|
204
|
+
Example:
|
|
205
|
+
redis = MockRedis()
|
|
206
|
+
|
|
207
|
+
# Basic operations
|
|
208
|
+
await redis.set("user:1:name", "John")
|
|
209
|
+
name = await redis.get("user:1:name")
|
|
210
|
+
assert name == "John"
|
|
211
|
+
|
|
212
|
+
# With expiration
|
|
213
|
+
await redis.set("session:abc", "data", ex=3600)
|
|
214
|
+
|
|
215
|
+
# Delete
|
|
216
|
+
await redis.delete("user:1:name")
|
|
217
|
+
assert await redis.get("user:1:name") is None
|
|
218
|
+
|
|
219
|
+
# Hash operations
|
|
220
|
+
await redis.hset("user:1", "name", "John")
|
|
221
|
+
await redis.hset("user:1", "email", "john@example.com")
|
|
222
|
+
user = await redis.hgetall("user:1")
|
|
223
|
+
assert user == {"name": "John", "email": "john@example.com"}
|
|
224
|
+
"""
|
|
225
|
+
|
|
226
|
+
data: dict[str, Any] = field(default_factory=dict)
|
|
227
|
+
_expiry: dict[str, datetime] = field(default_factory=dict)
|
|
228
|
+
_hash_data: dict[str, dict[str, Any]] = field(default_factory=dict)
|
|
229
|
+
_list_data: dict[str, list[Any]] = field(default_factory=dict)
|
|
230
|
+
_set_data: dict[str, set[Any]] = field(default_factory=dict)
|
|
231
|
+
|
|
232
|
+
async def get(self, key: str) -> Any | None:
|
|
233
|
+
"""Get value by key."""
|
|
234
|
+
self._check_expiry(key)
|
|
235
|
+
return self.data.get(key)
|
|
236
|
+
|
|
237
|
+
async def set(
|
|
238
|
+
self,
|
|
239
|
+
key: str,
|
|
240
|
+
value: Any,
|
|
241
|
+
ex: int | None = None,
|
|
242
|
+
px: int | None = None,
|
|
243
|
+
nx: bool = False,
|
|
244
|
+
xx: bool = False,
|
|
245
|
+
) -> bool:
|
|
246
|
+
"""
|
|
247
|
+
Set value.
|
|
248
|
+
|
|
249
|
+
Args:
|
|
250
|
+
key: Key to set
|
|
251
|
+
value: Value to store
|
|
252
|
+
ex: Expiration in seconds
|
|
253
|
+
px: Expiration in milliseconds
|
|
254
|
+
nx: Only set if key doesn't exist
|
|
255
|
+
xx: Only set if key exists
|
|
256
|
+
"""
|
|
257
|
+
if nx and key in self.data:
|
|
258
|
+
return False
|
|
259
|
+
if xx and key not in self.data:
|
|
260
|
+
return False
|
|
261
|
+
|
|
262
|
+
self.data[key] = value
|
|
263
|
+
|
|
264
|
+
if ex:
|
|
265
|
+
self._expiry[key] = datetime.now() + timedelta(seconds=ex)
|
|
266
|
+
elif px:
|
|
267
|
+
self._expiry[key] = datetime.now() + timedelta(milliseconds=px)
|
|
268
|
+
|
|
269
|
+
return True
|
|
270
|
+
|
|
271
|
+
async def setex(self, key: str, seconds: int, value: Any) -> bool:
|
|
272
|
+
"""Set value with expiration in seconds."""
|
|
273
|
+
return await self.set(key, value, ex=seconds)
|
|
274
|
+
|
|
275
|
+
async def delete(self, *keys: str) -> int:
|
|
276
|
+
"""Delete keys. Returns number of deleted keys."""
|
|
277
|
+
count = 0
|
|
278
|
+
for key in keys:
|
|
279
|
+
if key in self.data:
|
|
280
|
+
del self.data[key]
|
|
281
|
+
count += 1
|
|
282
|
+
self._expiry.pop(key, None)
|
|
283
|
+
return count
|
|
284
|
+
|
|
285
|
+
async def exists(self, *keys: str) -> int:
|
|
286
|
+
"""Check if keys exist. Returns count of existing keys."""
|
|
287
|
+
count = 0
|
|
288
|
+
for key in keys:
|
|
289
|
+
self._check_expiry(key)
|
|
290
|
+
if key in self.data:
|
|
291
|
+
count += 1
|
|
292
|
+
return count
|
|
293
|
+
|
|
294
|
+
async def incr(self, key: str) -> int:
|
|
295
|
+
"""Increment value by 1."""
|
|
296
|
+
self._check_expiry(key)
|
|
297
|
+
value = int(self.data.get(key, 0)) + 1
|
|
298
|
+
self.data[key] = value
|
|
299
|
+
return value
|
|
300
|
+
|
|
301
|
+
async def decr(self, key: str) -> int:
|
|
302
|
+
"""Decrement value by 1."""
|
|
303
|
+
self._check_expiry(key)
|
|
304
|
+
value = int(self.data.get(key, 0)) - 1
|
|
305
|
+
self.data[key] = value
|
|
306
|
+
return value
|
|
307
|
+
|
|
308
|
+
async def expire(self, key: str, seconds: int) -> bool:
|
|
309
|
+
"""Set expiration on key."""
|
|
310
|
+
if key in self.data:
|
|
311
|
+
self._expiry[key] = datetime.now() + timedelta(seconds=seconds)
|
|
312
|
+
return True
|
|
313
|
+
return False
|
|
314
|
+
|
|
315
|
+
async def ttl(self, key: str) -> int:
|
|
316
|
+
"""Get TTL in seconds. Returns -1 if no expiry, -2 if key doesn't exist."""
|
|
317
|
+
if key not in self.data:
|
|
318
|
+
return -2
|
|
319
|
+
if key not in self._expiry:
|
|
320
|
+
return -1
|
|
321
|
+
remaining = (self._expiry[key] - datetime.now()).total_seconds()
|
|
322
|
+
return max(0, int(remaining))
|
|
323
|
+
|
|
324
|
+
# Hash operations
|
|
325
|
+
async def hset(self, name: str, key: str, value: Any) -> int:
|
|
326
|
+
"""Set hash field."""
|
|
327
|
+
if name not in self._hash_data:
|
|
328
|
+
self._hash_data[name] = {}
|
|
329
|
+
is_new = key not in self._hash_data[name]
|
|
330
|
+
self._hash_data[name][key] = value
|
|
331
|
+
return 1 if is_new else 0
|
|
332
|
+
|
|
333
|
+
async def hget(self, name: str, key: str) -> Any | None:
|
|
334
|
+
"""Get hash field."""
|
|
335
|
+
return self._hash_data.get(name, {}).get(key)
|
|
336
|
+
|
|
337
|
+
async def hgetall(self, name: str) -> dict[str, Any]:
|
|
338
|
+
"""Get all hash fields."""
|
|
339
|
+
return self._hash_data.get(name, {}).copy()
|
|
340
|
+
|
|
341
|
+
async def hdel(self, name: str, *keys: str) -> int:
|
|
342
|
+
"""Delete hash fields."""
|
|
343
|
+
if name not in self._hash_data:
|
|
344
|
+
return 0
|
|
345
|
+
count = 0
|
|
346
|
+
for key in keys:
|
|
347
|
+
if key in self._hash_data[name]:
|
|
348
|
+
del self._hash_data[name][key]
|
|
349
|
+
count += 1
|
|
350
|
+
return count
|
|
351
|
+
|
|
352
|
+
# List operations
|
|
353
|
+
async def lpush(self, name: str, *values: Any) -> int:
|
|
354
|
+
"""Push values to list head."""
|
|
355
|
+
if name not in self._list_data:
|
|
356
|
+
self._list_data[name] = []
|
|
357
|
+
for value in values:
|
|
358
|
+
self._list_data[name].insert(0, value)
|
|
359
|
+
return len(self._list_data[name])
|
|
360
|
+
|
|
361
|
+
async def rpush(self, name: str, *values: Any) -> int:
|
|
362
|
+
"""Push values to list tail."""
|
|
363
|
+
if name not in self._list_data:
|
|
364
|
+
self._list_data[name] = []
|
|
365
|
+
self._list_data[name].extend(values)
|
|
366
|
+
return len(self._list_data[name])
|
|
367
|
+
|
|
368
|
+
async def lrange(self, name: str, start: int, end: int) -> list[Any]:
|
|
369
|
+
"""Get list range."""
|
|
370
|
+
lst = self._list_data.get(name, [])
|
|
371
|
+
if end == -1:
|
|
372
|
+
return lst[start:]
|
|
373
|
+
return lst[start:end + 1]
|
|
374
|
+
|
|
375
|
+
async def llen(self, name: str) -> int:
|
|
376
|
+
"""Get list length."""
|
|
377
|
+
return len(self._list_data.get(name, []))
|
|
378
|
+
|
|
379
|
+
# Set operations
|
|
380
|
+
async def sadd(self, name: str, *values: Any) -> int:
|
|
381
|
+
"""Add values to set."""
|
|
382
|
+
if name not in self._set_data:
|
|
383
|
+
self._set_data[name] = set()
|
|
384
|
+
before = len(self._set_data[name])
|
|
385
|
+
self._set_data[name].update(values)
|
|
386
|
+
return len(self._set_data[name]) - before
|
|
387
|
+
|
|
388
|
+
async def smembers(self, name: str) -> set[Any]:
|
|
389
|
+
"""Get all set members."""
|
|
390
|
+
return self._set_data.get(name, set()).copy()
|
|
391
|
+
|
|
392
|
+
async def sismember(self, name: str, value: Any) -> bool:
|
|
393
|
+
"""Check if value is in set."""
|
|
394
|
+
return value in self._set_data.get(name, set())
|
|
395
|
+
|
|
396
|
+
def _check_expiry(self, key: str) -> None:
|
|
397
|
+
"""Check and remove expired key."""
|
|
398
|
+
if key in self._expiry and datetime.now() > self._expiry[key]:
|
|
399
|
+
self.data.pop(key, None)
|
|
400
|
+
del self._expiry[key]
|
|
401
|
+
|
|
402
|
+
def clear(self) -> None:
|
|
403
|
+
"""Clear all data."""
|
|
404
|
+
self.data.clear()
|
|
405
|
+
self._expiry.clear()
|
|
406
|
+
self._hash_data.clear()
|
|
407
|
+
self._list_data.clear()
|
|
408
|
+
self._set_data.clear()
|
|
409
|
+
logger.debug("MockRedis: cleared all data")
|
|
410
|
+
|
|
411
|
+
|
|
412
|
+
# =============================================================================
|
|
413
|
+
# HTTP Mocks
|
|
414
|
+
# =============================================================================
|
|
415
|
+
|
|
416
|
+
class MockHTTPResponse:
|
|
417
|
+
"""
|
|
418
|
+
Builder for mock HTTP responses.
|
|
419
|
+
|
|
420
|
+
Example:
|
|
421
|
+
http.when("GET", "/users/1").respond(
|
|
422
|
+
status=200,
|
|
423
|
+
json={"id": 1, "name": "John"}
|
|
424
|
+
)
|
|
425
|
+
"""
|
|
426
|
+
|
|
427
|
+
def __init__(self, mock: "MockHTTP", method: str, url: str) -> None:
|
|
428
|
+
self.mock = mock
|
|
429
|
+
self.method = method
|
|
430
|
+
self.url = url
|
|
431
|
+
self.key = f"{method}:{url}"
|
|
432
|
+
|
|
433
|
+
def respond(
|
|
434
|
+
self,
|
|
435
|
+
status: int = 200,
|
|
436
|
+
json: dict[str, Any] | None = None,
|
|
437
|
+
text: str | None = None,
|
|
438
|
+
headers: dict[str, str] | None = None,
|
|
439
|
+
) -> "MockHTTP":
|
|
440
|
+
"""
|
|
441
|
+
Set the response for this mock.
|
|
442
|
+
|
|
443
|
+
Args:
|
|
444
|
+
status: HTTP status code
|
|
445
|
+
json: JSON response body
|
|
446
|
+
text: Text response body
|
|
447
|
+
headers: Response headers
|
|
448
|
+
|
|
449
|
+
Returns:
|
|
450
|
+
The MockHTTP instance for chaining
|
|
451
|
+
"""
|
|
452
|
+
self.mock._responses[self.key] = {
|
|
453
|
+
"status": status,
|
|
454
|
+
"json": json,
|
|
455
|
+
"text": text,
|
|
456
|
+
"headers": headers or {},
|
|
457
|
+
}
|
|
458
|
+
return self.mock
|
|
459
|
+
|
|
460
|
+
def respond_with_error(
|
|
461
|
+
self,
|
|
462
|
+
status: int = 500,
|
|
463
|
+
message: str = "Internal Server Error",
|
|
464
|
+
) -> "MockHTTP":
|
|
465
|
+
"""Set an error response."""
|
|
466
|
+
return self.respond(
|
|
467
|
+
status=status,
|
|
468
|
+
json={"error": message, "status": status},
|
|
469
|
+
)
|
|
470
|
+
|
|
471
|
+
def respond_with_timeout(self) -> "MockHTTP":
|
|
472
|
+
"""Set a timeout response."""
|
|
473
|
+
self.mock._responses[self.key] = {"timeout": True}
|
|
474
|
+
return self.mock
|
|
475
|
+
|
|
476
|
+
|
|
477
|
+
@dataclass
|
|
478
|
+
class MockHTTPRequest:
|
|
479
|
+
"""Recorded HTTP request."""
|
|
480
|
+
method: str
|
|
481
|
+
url: str
|
|
482
|
+
json: dict[str, Any] | None = None
|
|
483
|
+
data: Any = None
|
|
484
|
+
headers: dict[str, str] | None = None
|
|
485
|
+
timestamp: datetime = field(default_factory=datetime.now)
|
|
486
|
+
|
|
487
|
+
|
|
488
|
+
class MockHTTP:
|
|
489
|
+
"""
|
|
490
|
+
Mock HTTP client for external API calls.
|
|
491
|
+
|
|
492
|
+
Example:
|
|
493
|
+
http = MockHTTP()
|
|
494
|
+
|
|
495
|
+
# Configure mock responses
|
|
496
|
+
http.when("GET", "https://api.example.com/users/1").respond(
|
|
497
|
+
status=200,
|
|
498
|
+
json={"id": 1, "name": "John"}
|
|
499
|
+
)
|
|
500
|
+
|
|
501
|
+
http.when("POST", "https://api.example.com/users").respond(
|
|
502
|
+
status=201,
|
|
503
|
+
json={"id": 2, "name": "Jane"}
|
|
504
|
+
)
|
|
505
|
+
|
|
506
|
+
# Use in tests
|
|
507
|
+
response = await http.request("GET", "https://api.example.com/users/1")
|
|
508
|
+
assert response["status"] == 200
|
|
509
|
+
assert response["json"]["name"] == "John"
|
|
510
|
+
|
|
511
|
+
# Assert requests were made
|
|
512
|
+
http.assert_called("GET", "https://api.example.com/users/1")
|
|
513
|
+
"""
|
|
514
|
+
|
|
515
|
+
def __init__(self) -> None:
|
|
516
|
+
self._responses: dict[str, dict[str, Any]] = {}
|
|
517
|
+
self._requests: list[MockHTTPRequest] = []
|
|
518
|
+
|
|
519
|
+
def when(self, method: str, url: str) -> MockHTTPResponse:
|
|
520
|
+
"""
|
|
521
|
+
Configure a mock response for a request.
|
|
522
|
+
|
|
523
|
+
Args:
|
|
524
|
+
method: HTTP method (GET, POST, etc.)
|
|
525
|
+
url: URL to mock
|
|
526
|
+
|
|
527
|
+
Returns:
|
|
528
|
+
MockHTTPResponse builder
|
|
529
|
+
"""
|
|
530
|
+
return MockHTTPResponse(self, method.upper(), url)
|
|
531
|
+
|
|
532
|
+
async def request(
|
|
533
|
+
self,
|
|
534
|
+
method: str,
|
|
535
|
+
url: str,
|
|
536
|
+
json: dict[str, Any] | None = None,
|
|
537
|
+
data: Any = None,
|
|
538
|
+
headers: dict[str, str] | None = None,
|
|
539
|
+
**kwargs,
|
|
540
|
+
) -> dict[str, Any]:
|
|
541
|
+
"""
|
|
542
|
+
Execute a mocked HTTP request.
|
|
543
|
+
|
|
544
|
+
Args:
|
|
545
|
+
method: HTTP method
|
|
546
|
+
url: URL to request
|
|
547
|
+
json: JSON body
|
|
548
|
+
data: Form data
|
|
549
|
+
headers: Request headers
|
|
550
|
+
|
|
551
|
+
Returns:
|
|
552
|
+
Mock response dict with status, json, text, headers
|
|
553
|
+
"""
|
|
554
|
+
method = method.upper()
|
|
555
|
+
|
|
556
|
+
# Record request
|
|
557
|
+
self._requests.append(MockHTTPRequest(
|
|
558
|
+
method=method,
|
|
559
|
+
url=url,
|
|
560
|
+
json=json,
|
|
561
|
+
data=data,
|
|
562
|
+
headers=headers,
|
|
563
|
+
))
|
|
564
|
+
|
|
565
|
+
# Find mock response
|
|
566
|
+
key = f"{method}:{url}"
|
|
567
|
+
response = self._responses.get(key)
|
|
568
|
+
|
|
569
|
+
if response is None:
|
|
570
|
+
logger.warning(f"MockHTTP: No mock for {method} {url}")
|
|
571
|
+
return {"status": 404, "json": {"error": "Not found"}}
|
|
572
|
+
|
|
573
|
+
if response.get("timeout"):
|
|
574
|
+
raise TimeoutError(f"Mock timeout for {method} {url}")
|
|
575
|
+
|
|
576
|
+
return response
|
|
577
|
+
|
|
578
|
+
# Convenience methods
|
|
579
|
+
async def get(self, url: str, **kwargs) -> dict[str, Any]:
|
|
580
|
+
"""GET request."""
|
|
581
|
+
return await self.request("GET", url, **kwargs)
|
|
582
|
+
|
|
583
|
+
async def post(self, url: str, **kwargs) -> dict[str, Any]:
|
|
584
|
+
"""POST request."""
|
|
585
|
+
return await self.request("POST", url, **kwargs)
|
|
586
|
+
|
|
587
|
+
async def put(self, url: str, **kwargs) -> dict[str, Any]:
|
|
588
|
+
"""PUT request."""
|
|
589
|
+
return await self.request("PUT", url, **kwargs)
|
|
590
|
+
|
|
591
|
+
async def delete(self, url: str, **kwargs) -> dict[str, Any]:
|
|
592
|
+
"""DELETE request."""
|
|
593
|
+
return await self.request("DELETE", url, **kwargs)
|
|
594
|
+
|
|
595
|
+
def get_requests(
|
|
596
|
+
self,
|
|
597
|
+
method: str | None = None,
|
|
598
|
+
url: str | None = None,
|
|
599
|
+
) -> list[MockHTTPRequest]:
|
|
600
|
+
"""Get recorded requests, optionally filtered."""
|
|
601
|
+
requests = self._requests
|
|
602
|
+
if method:
|
|
603
|
+
requests = [r for r in requests if r.method == method.upper()]
|
|
604
|
+
if url:
|
|
605
|
+
requests = [r for r in requests if r.url == url]
|
|
606
|
+
return requests
|
|
607
|
+
|
|
608
|
+
def assert_called(
|
|
609
|
+
self,
|
|
610
|
+
method: str,
|
|
611
|
+
url: str,
|
|
612
|
+
times: int = 1,
|
|
613
|
+
) -> None:
|
|
614
|
+
"""
|
|
615
|
+
Assert a request was made.
|
|
616
|
+
|
|
617
|
+
Args:
|
|
618
|
+
method: Expected HTTP method
|
|
619
|
+
url: Expected URL
|
|
620
|
+
times: Expected number of calls
|
|
621
|
+
|
|
622
|
+
Raises:
|
|
623
|
+
AssertionError: If assertion fails
|
|
624
|
+
"""
|
|
625
|
+
requests = self.get_requests(method, url)
|
|
626
|
+
assert len(requests) == times, (
|
|
627
|
+
f"Expected {times} calls to {method} {url}, got {len(requests)}. "
|
|
628
|
+
f"All requests: {[(r.method, r.url) for r in self._requests]}"
|
|
629
|
+
)
|
|
630
|
+
|
|
631
|
+
def assert_called_with_json(
|
|
632
|
+
self,
|
|
633
|
+
method: str,
|
|
634
|
+
url: str,
|
|
635
|
+
json: dict[str, Any],
|
|
636
|
+
) -> None:
|
|
637
|
+
"""Assert a request was made with specific JSON body."""
|
|
638
|
+
requests = self.get_requests(method, url)
|
|
639
|
+
for req in requests:
|
|
640
|
+
if req.json == json:
|
|
641
|
+
return
|
|
642
|
+
raise AssertionError(
|
|
643
|
+
f"No {method} {url} call with json={json}. "
|
|
644
|
+
f"Requests: {[(r.method, r.url, r.json) for r in requests]}"
|
|
645
|
+
)
|
|
646
|
+
|
|
647
|
+
def assert_not_called(self, method: str, url: str) -> None:
|
|
648
|
+
"""Assert a request was NOT made."""
|
|
649
|
+
requests = self.get_requests(method, url)
|
|
650
|
+
assert len(requests) == 0, (
|
|
651
|
+
f"Expected no calls to {method} {url}, got {len(requests)}"
|
|
652
|
+
)
|
|
653
|
+
|
|
654
|
+
def clear(self) -> None:
|
|
655
|
+
"""Clear all mocks and recorded requests."""
|
|
656
|
+
self._responses.clear()
|
|
657
|
+
self._requests.clear()
|
|
658
|
+
logger.debug("MockHTTP: cleared mocks and requests")
|