core-framework 0.12.7__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/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")