sefrone-api-e2e 1.0.2__py3-none-any.whl → 1.0.4__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.
- sefrone_api_e2e/__init__.py +5 -1
- sefrone_api_e2e/api_e2e_manager.py +216 -56
- sefrone_api_e2e/email_mock_server.py +480 -0
- sefrone_api_e2e/json_mock_server.py +309 -0
- {sefrone_api_e2e-1.0.2.dist-info → sefrone_api_e2e-1.0.4.dist-info}/METADATA +1 -1
- sefrone_api_e2e-1.0.4.dist-info/RECORD +8 -0
- sefrone_api_e2e-1.0.2.dist-info/RECORD +0 -6
- {sefrone_api_e2e-1.0.2.dist-info → sefrone_api_e2e-1.0.4.dist-info}/WHEEL +0 -0
- {sefrone_api_e2e-1.0.2.dist-info → sefrone_api_e2e-1.0.4.dist-info}/top_level.txt +0 -0
sefrone_api_e2e/__init__.py
CHANGED
|
@@ -27,9 +27,11 @@ class ApiE2ETestsManager:
|
|
|
27
27
|
ApiE2ETestsManager.scenario_results = []
|
|
28
28
|
|
|
29
29
|
p = Path(folder_path)
|
|
30
|
-
scenario_files = sorted([f.name for f in p.iterdir() if f.is_file() and f.suffix == ".yaml"])
|
|
30
|
+
scenario_files = sorted([f.name for f in p.iterdir() if f.is_file() and f.suffix == ".yaml"])
|
|
31
31
|
total_start = time.time()
|
|
32
32
|
|
|
33
|
+
all_tests_passed = True
|
|
34
|
+
|
|
33
35
|
for filename in scenario_files:
|
|
34
36
|
yaml_file_path = os.path.join(folder_path, filename)
|
|
35
37
|
scenario_name = os.path.splitext(filename)[0]
|
|
@@ -55,6 +57,7 @@ class ApiE2ETestsManager:
|
|
|
55
57
|
"steps": step_metrics,
|
|
56
58
|
"total_time": elapsed
|
|
57
59
|
})
|
|
60
|
+
all_tests_passed = False
|
|
58
61
|
# mark remaining scenarios as skipped
|
|
59
62
|
remaining = scenario_files[scenario_files.index(filename)+1:]
|
|
60
63
|
for skipped_file in remaining:
|
|
@@ -76,6 +79,7 @@ class ApiE2ETestsManager:
|
|
|
76
79
|
"steps": step_metrics if 'step_metrics' in locals() else [],
|
|
77
80
|
"total_time": failed_elapsed
|
|
78
81
|
})
|
|
82
|
+
all_tests_passed = False
|
|
79
83
|
# mark remaining scenarios as skipped
|
|
80
84
|
remaining = scenario_files[scenario_files.index(filename)+1:]
|
|
81
85
|
for skipped_file in remaining:
|
|
@@ -90,7 +94,10 @@ class ApiE2ETestsManager:
|
|
|
90
94
|
|
|
91
95
|
total_elapsed = time.time() - total_start
|
|
92
96
|
print("\nAPI E2E tests completed.")
|
|
93
|
-
ApiE2ETestsManager.print_summary(total_elapsed)
|
|
97
|
+
ApiE2ETestsManager.print_summary(total_elapsed, all_tests_passed)
|
|
98
|
+
|
|
99
|
+
if not all_tests_passed:
|
|
100
|
+
exit(1)
|
|
94
101
|
|
|
95
102
|
@staticmethod
|
|
96
103
|
def read_env_value_from_file(env_file_path, env_var):
|
|
@@ -167,13 +174,100 @@ class ApiE2ETestsManager:
|
|
|
167
174
|
@staticmethod
|
|
168
175
|
def render_template(template_str, context, add_quotes=False):
|
|
169
176
|
def resolve_path(expr, ctx):
|
|
170
|
-
|
|
177
|
+
# Parse path with array indexing support: body.items[0].id and count(): body.items.count()
|
|
171
178
|
val = ctx
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
179
|
+
current_path = ""
|
|
180
|
+
i = 0
|
|
181
|
+
|
|
182
|
+
while i < len(expr):
|
|
183
|
+
# Check for array index
|
|
184
|
+
if expr[i] == '[':
|
|
185
|
+
# Find closing bracket
|
|
186
|
+
end = expr.find(']', i)
|
|
187
|
+
if end == -1:
|
|
188
|
+
raise ValueError(f"Unclosed bracket in path: {expr}")
|
|
189
|
+
|
|
190
|
+
# Extract index
|
|
191
|
+
index_str = expr[i+1:end].strip()
|
|
192
|
+
try:
|
|
193
|
+
index = int(index_str)
|
|
194
|
+
except ValueError:
|
|
195
|
+
raise ValueError(f"Invalid array index '{index_str}' in path: {expr}")
|
|
196
|
+
|
|
197
|
+
# Apply index
|
|
198
|
+
if not isinstance(val, list):
|
|
199
|
+
raise KeyError(f"Cannot index non-list at '{current_path}' in path: {expr}")
|
|
200
|
+
if index < 0 or index >= len(val):
|
|
201
|
+
raise KeyError(f"Index {index} out of range at '{current_path}' in path: {expr}")
|
|
202
|
+
|
|
203
|
+
val = val[index]
|
|
204
|
+
current_path += f"[{index}]"
|
|
205
|
+
i = end + 1
|
|
206
|
+
|
|
207
|
+
# Skip dot after bracket if present
|
|
208
|
+
if i < len(expr) and expr[i] == '.':
|
|
209
|
+
i += 1
|
|
210
|
+
|
|
211
|
+
# Check for dot separator
|
|
212
|
+
elif expr[i] == '.':
|
|
213
|
+
i += 1
|
|
214
|
+
|
|
215
|
+
# Read property name or function call
|
|
175
216
|
else:
|
|
176
|
-
|
|
217
|
+
# Find next special char
|
|
218
|
+
next_dot = expr.find('.', i)
|
|
219
|
+
next_bracket = expr.find('[', i)
|
|
220
|
+
next_paren = expr.find('(', i)
|
|
221
|
+
|
|
222
|
+
# Determine end of property name
|
|
223
|
+
candidates = [next_dot, next_bracket, next_paren]
|
|
224
|
+
valid_candidates = [c for c in candidates if c != -1]
|
|
225
|
+
|
|
226
|
+
if not valid_candidates:
|
|
227
|
+
end_pos = len(expr)
|
|
228
|
+
else:
|
|
229
|
+
end_pos = min(valid_candidates)
|
|
230
|
+
|
|
231
|
+
prop = expr[i:end_pos].strip()
|
|
232
|
+
if prop:
|
|
233
|
+
# Check if it's a function call
|
|
234
|
+
if end_pos < len(expr) and expr[end_pos] == '(':
|
|
235
|
+
# Handle count() function
|
|
236
|
+
if prop == "count":
|
|
237
|
+
# Find closing parenthesis
|
|
238
|
+
close_paren = expr.find(')', end_pos)
|
|
239
|
+
if close_paren == -1:
|
|
240
|
+
raise ValueError(f"Unclosed parenthesis in function call: {expr}")
|
|
241
|
+
|
|
242
|
+
# Check if there are arguments (should be empty for count)
|
|
243
|
+
args = expr[end_pos+1:close_paren].strip()
|
|
244
|
+
if args:
|
|
245
|
+
raise ValueError(f"count() function does not accept arguments: {expr}")
|
|
246
|
+
|
|
247
|
+
# Apply count on lists or dicts
|
|
248
|
+
if isinstance(val, (list, dict)):
|
|
249
|
+
val = len(val)
|
|
250
|
+
else:
|
|
251
|
+
raise KeyError(f"Cannot call count() on type {type(val).__name__} at '{current_path}' in path: {expr}")
|
|
252
|
+
|
|
253
|
+
current_path += ".count()"
|
|
254
|
+
i = close_paren + 1
|
|
255
|
+
|
|
256
|
+
# Skip dot after parenthesis if present
|
|
257
|
+
if i < len(expr) and expr[i] == '.':
|
|
258
|
+
i += 1
|
|
259
|
+
else:
|
|
260
|
+
raise ValueError(f"Unsupported function '{prop}()' in path: {expr}")
|
|
261
|
+
# Regular property access
|
|
262
|
+
elif isinstance(val, dict) and prop in val:
|
|
263
|
+
val = val[prop]
|
|
264
|
+
current_path += f".{prop}" if current_path and not current_path.endswith(']') else prop
|
|
265
|
+
i = end_pos
|
|
266
|
+
else:
|
|
267
|
+
raise KeyError(f"Cannot resolve '{expr}' in context: property '{prop}' not found at '{current_path}'")
|
|
268
|
+
else:
|
|
269
|
+
i = end_pos
|
|
270
|
+
|
|
177
271
|
return val
|
|
178
272
|
|
|
179
273
|
def replacer(match):
|
|
@@ -208,6 +302,20 @@ class ApiE2ETestsManager:
|
|
|
208
302
|
|
|
209
303
|
return ApiE2ETestsManager.STORED_PATTERN.sub(repl, expr)
|
|
210
304
|
|
|
305
|
+
@staticmethod
|
|
306
|
+
def substitute_stored_in_body(body, scenario_name):
|
|
307
|
+
"""Recursively substitute stored variables in request body."""
|
|
308
|
+
if body is None:
|
|
309
|
+
return None
|
|
310
|
+
elif isinstance(body, dict):
|
|
311
|
+
return {k: ApiE2ETestsManager.substitute_stored_in_body(v, scenario_name) for k, v in body.items()}
|
|
312
|
+
elif isinstance(body, list):
|
|
313
|
+
return [ApiE2ETestsManager.substitute_stored_in_body(item, scenario_name) for item in body]
|
|
314
|
+
elif isinstance(body, str):
|
|
315
|
+
return ApiE2ETestsManager.substitute_stored(body, scenario_name)
|
|
316
|
+
else:
|
|
317
|
+
return body
|
|
318
|
+
|
|
211
319
|
@staticmethod
|
|
212
320
|
def store_value(key, value, scenario_name):
|
|
213
321
|
namespaced_key = f"{scenario_name}.{key}"
|
|
@@ -275,7 +383,7 @@ class ApiE2ETestsManager:
|
|
|
275
383
|
|
|
276
384
|
if not result:
|
|
277
385
|
raise AssertionError(
|
|
278
|
-
f"Assertion failed: {expr}
|
|
386
|
+
f"Assertion failed: {expr} -> evaluated as {left_val} {matched_op} {right_val}"
|
|
279
387
|
)
|
|
280
388
|
return True
|
|
281
389
|
|
|
@@ -286,67 +394,114 @@ class ApiE2ETestsManager:
|
|
|
286
394
|
step_metrics = []
|
|
287
395
|
|
|
288
396
|
print(f"Running test: {spec.get('name', scenario_name)}")
|
|
397
|
+
print(f"Base URL: {base_url}")
|
|
289
398
|
print("=" * 60)
|
|
290
399
|
# use a session for connection pooling and to set a default timeout
|
|
291
400
|
session = requests.Session()
|
|
292
401
|
for step in steps:
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
method = step['method'].upper()
|
|
402
|
+
# Get retry configuration
|
|
403
|
+
retry_config = step.get("retry", {})
|
|
404
|
+
max_attempts = retry_config.get("attempts", 1)
|
|
405
|
+
delay_seconds = retry_config.get("delay_seconds", 0)
|
|
298
406
|
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
for header in step["request_headers"]:
|
|
302
|
-
for header_key, header_value in header.items():
|
|
303
|
-
if isinstance(header_value, str):
|
|
304
|
-
header_value = ApiE2ETestsManager.substitute_stored(header_value, scenario_name)
|
|
305
|
-
request_headers[header_key] = header_value
|
|
306
|
-
|
|
307
|
-
body = step.get("body")
|
|
308
|
-
expect = step.get("expect", {})
|
|
309
|
-
|
|
310
|
-
resp = session.request(method, url, json=body, headers=request_headers, timeout=ApiE2ETestsManager.DEFAULT_TIMEOUT)
|
|
311
|
-
print(f" -> {method} {url} -> {resp.status_code}")
|
|
312
|
-
|
|
313
|
-
expected_status = expect.get("status")
|
|
314
|
-
if expected_status and resp.status_code != expected_status:
|
|
315
|
-
raise AssertionError(f"Expected {expected_status}, got {resp.status_code}")
|
|
407
|
+
last_error = None
|
|
408
|
+
step_start = time.time()
|
|
316
409
|
|
|
410
|
+
for attempt in range(max_attempts):
|
|
317
411
|
try:
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
ApiE2ETestsManager.
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
412
|
+
if attempt > 0:
|
|
413
|
+
print(f" Retry attempt {attempt + 1}/{max_attempts} after {delay_seconds}s delay...")
|
|
414
|
+
time.sleep(delay_seconds)
|
|
415
|
+
|
|
416
|
+
if attempt == 0:
|
|
417
|
+
print(f"Step: {step['name']}")
|
|
418
|
+
|
|
419
|
+
url = base_url + ApiE2ETestsManager.substitute_stored(step['endpoint'], scenario_name)
|
|
420
|
+
method = step['method'].upper()
|
|
421
|
+
|
|
422
|
+
request_headers = {}
|
|
423
|
+
if "request_headers" in step:
|
|
424
|
+
for header in step["request_headers"]:
|
|
425
|
+
for header_key, header_value in header.items():
|
|
426
|
+
if isinstance(header_value, str):
|
|
427
|
+
header_value = ApiE2ETestsManager.substitute_stored(header_value, scenario_name)
|
|
428
|
+
request_headers[header_key] = header_value
|
|
429
|
+
|
|
430
|
+
request_query_params = {}
|
|
431
|
+
if "request_query_params" in step:
|
|
432
|
+
params = step["request_query_params"]
|
|
433
|
+
if isinstance(params, dict):
|
|
434
|
+
for param_key, param_value in params.items():
|
|
435
|
+
if isinstance(param_value, str):
|
|
436
|
+
param_value = ApiE2ETestsManager.substitute_stored(param_value, scenario_name)
|
|
437
|
+
request_query_params[param_key] = param_value
|
|
438
|
+
else:
|
|
439
|
+
raise ValueError(f"query_params must be a dictionary, got {type(params)}")
|
|
440
|
+
|
|
441
|
+
body = step.get("body")
|
|
442
|
+
if body is not None:
|
|
443
|
+
body = ApiE2ETestsManager.substitute_stored_in_body(body, scenario_name)
|
|
444
|
+
expect = step.get("expect", {})
|
|
445
|
+
|
|
446
|
+
resp = session.request(method, url, json=body, headers=request_headers, params=request_query_params, timeout=ApiE2ETestsManager.DEFAULT_TIMEOUT)
|
|
447
|
+
print(f" -> {method} {url} -> {resp.status_code}")
|
|
448
|
+
|
|
449
|
+
expected_status = expect.get("status")
|
|
450
|
+
if expected_status and resp.status_code != expected_status:
|
|
451
|
+
raise AssertionError(f"Expected {expected_status}, got {resp.status_code}")
|
|
452
|
+
|
|
453
|
+
try:
|
|
454
|
+
resp_json = resp.json()
|
|
455
|
+
except Exception:
|
|
456
|
+
raise AssertionError("Response is not valid JSON")
|
|
457
|
+
|
|
458
|
+
if "body" in expect:
|
|
459
|
+
if is_verbose:
|
|
460
|
+
print(f"Response body: {resp_json}")
|
|
461
|
+
ApiE2ETestsManager.validate_structure(resp_json, expect["body"])
|
|
462
|
+
|
|
463
|
+
if "assertions" in step:
|
|
464
|
+
for expr in step["assertions"]:
|
|
465
|
+
try:
|
|
466
|
+
rendered = ApiE2ETestsManager.render_template(expr, {"body": resp_json}, add_quotes=True)
|
|
467
|
+
ApiE2ETestsManager.try_eval(rendered, expr)
|
|
468
|
+
except Exception as e:
|
|
469
|
+
raise AssertionError(f"Assertion failed for expression\n -->'{expr}'\n -->{str(e)}")
|
|
470
|
+
|
|
471
|
+
if "save" in step:
|
|
472
|
+
for key, path_expr in step["save"].items():
|
|
473
|
+
try:
|
|
474
|
+
rendered = ApiE2ETestsManager.render_template(path_expr, {"body": resp_json})
|
|
475
|
+
ApiE2ETestsManager.store_value(key, rendered, scenario_name)
|
|
476
|
+
except Exception as e:
|
|
477
|
+
raise AssertionError(f"Failed to render save path\n -->'{path_expr}'\n -->{str(e)}")
|
|
478
|
+
|
|
479
|
+
elapsed = time.time() - step_start
|
|
480
|
+
step_metrics.append({"name": step["name"], "time": elapsed, "result": "PASS"})
|
|
481
|
+
if attempt > 0:
|
|
482
|
+
print(f"[OK ] Step passed on retry attempt {attempt + 1} in {elapsed:.2f}s\n")
|
|
483
|
+
else:
|
|
484
|
+
print(f"[OK ] Step passed in {elapsed:.2f}s\n")
|
|
485
|
+
last_error = None
|
|
486
|
+
break # Success, exit retry loop
|
|
487
|
+
|
|
488
|
+
except Exception as e:
|
|
489
|
+
last_error = e
|
|
490
|
+
if attempt < max_attempts - 1:
|
|
491
|
+
print(f"[RETRY] Attempt {attempt + 1} failed: {str(e)}")
|
|
492
|
+
# Continue to next attempt
|
|
493
|
+
|
|
494
|
+
# Check if all attempts failed
|
|
495
|
+
if last_error is not None:
|
|
341
496
|
failed_elapsed = time.time() - step_start
|
|
342
|
-
step_metrics.append({"name": step["name"], "time": failed_elapsed, "result": "FAIL", "error": str(
|
|
343
|
-
print(f"[FAIL] Step failed in {failed_elapsed:.2f}s
|
|
497
|
+
step_metrics.append({"name": step["name"], "time": failed_elapsed, "result": "FAIL", "error": str(last_error)})
|
|
498
|
+
print(f"[FAIL] Step failed after {max_attempts} attempt(s) in {failed_elapsed:.2f}s\n Error: {str(last_error)}\n")
|
|
344
499
|
break
|
|
345
500
|
print("=" * 60)
|
|
346
501
|
return step_metrics
|
|
347
502
|
|
|
348
503
|
@staticmethod
|
|
349
|
-
def print_summary(total_time):
|
|
504
|
+
def print_summary(total_time, all_tests_passed):
|
|
350
505
|
print("\n[SUMMARY] TEST RESULTS")
|
|
351
506
|
print("=" * 80)
|
|
352
507
|
|
|
@@ -382,3 +537,8 @@ class ApiE2ETestsManager:
|
|
|
382
537
|
|
|
383
538
|
print("=" * 80)
|
|
384
539
|
print(f"Total Execution Time: {total_time:.2f}s\n")
|
|
540
|
+
|
|
541
|
+
if all_tests_passed:
|
|
542
|
+
print("ALL TESTS PASSED")
|
|
543
|
+
else:
|
|
544
|
+
print("SOME TESTS FAILED")
|
|
@@ -0,0 +1,480 @@
|
|
|
1
|
+
import threading
|
|
2
|
+
import json
|
|
3
|
+
import socket
|
|
4
|
+
import email
|
|
5
|
+
from http.server import HTTPServer, BaseHTTPRequestHandler
|
|
6
|
+
from typing import List, Dict, Any, Optional
|
|
7
|
+
from datetime import datetime
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class EmailMockServer:
|
|
11
|
+
"""
|
|
12
|
+
Comprehensive mock email server that supports SMTP, IMAP, and HTTP protocols.
|
|
13
|
+
|
|
14
|
+
Features:
|
|
15
|
+
- SMTP server for receiving emails (standard protocol)
|
|
16
|
+
- IMAP server for checking emails (standard protocol)
|
|
17
|
+
- HTTP API for test automation and verification
|
|
18
|
+
- Query captured emails via GET /emails
|
|
19
|
+
- Clear emails via DELETE /emails
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
def __init__(self, host: str = "localhost", http_port: int = 8025, smtp_port: int = 1025, imap_port: int = 1143):
|
|
23
|
+
self.host = host
|
|
24
|
+
self.http_port = http_port
|
|
25
|
+
self.smtp_port = smtp_port
|
|
26
|
+
self.imap_port = imap_port
|
|
27
|
+
self.emails: List[Dict[str, Any]] = []
|
|
28
|
+
self._http_server: Optional[HTTPServer] = None
|
|
29
|
+
self._smtp_thread: Optional[threading.Thread] = None
|
|
30
|
+
self._imap_thread: Optional[threading.Thread] = None
|
|
31
|
+
self._http_thread: Optional[threading.Thread] = None
|
|
32
|
+
self._running = False
|
|
33
|
+
self._smtp_socket: Optional[socket.socket] = None
|
|
34
|
+
self._imap_socket: Optional[socket.socket] = None
|
|
35
|
+
self._next_uid = 1
|
|
36
|
+
|
|
37
|
+
def start(self):
|
|
38
|
+
"""Start all email server protocols in background threads."""
|
|
39
|
+
if self._running:
|
|
40
|
+
print(f"[EmailMockServer] Already running")
|
|
41
|
+
return
|
|
42
|
+
|
|
43
|
+
self._running = True
|
|
44
|
+
|
|
45
|
+
# Start HTTP server
|
|
46
|
+
handler = self._create_http_handler()
|
|
47
|
+
self._http_server = HTTPServer((self.host, self.http_port), handler)
|
|
48
|
+
self._http_thread = threading.Thread(target=self._http_server.serve_forever, daemon=True)
|
|
49
|
+
self._http_thread.start()
|
|
50
|
+
print(f"[EmailMockServer] HTTP API started on http://{self.host}:{self.http_port}")
|
|
51
|
+
|
|
52
|
+
# Start SMTP server
|
|
53
|
+
self._smtp_thread = threading.Thread(target=self._run_smtp_server, daemon=True)
|
|
54
|
+
self._smtp_thread.start()
|
|
55
|
+
print(f"[EmailMockServer] SMTP server started on {self.host}:{self.smtp_port}")
|
|
56
|
+
|
|
57
|
+
# Start IMAP server
|
|
58
|
+
self._imap_thread = threading.Thread(target=self._run_imap_server, daemon=True)
|
|
59
|
+
self._imap_thread.start()
|
|
60
|
+
print(f"[EmailMockServer] IMAP server started on {self.host}:{self.imap_port}")
|
|
61
|
+
|
|
62
|
+
def stop(self):
|
|
63
|
+
"""Stop all email server protocols."""
|
|
64
|
+
if not self._running:
|
|
65
|
+
return
|
|
66
|
+
|
|
67
|
+
self._running = False
|
|
68
|
+
|
|
69
|
+
# Stop HTTP server
|
|
70
|
+
if self._http_server:
|
|
71
|
+
self._http_server.shutdown()
|
|
72
|
+
self._http_server.server_close()
|
|
73
|
+
if self._http_thread:
|
|
74
|
+
self._http_thread.join(timeout=2)
|
|
75
|
+
|
|
76
|
+
# Stop SMTP server
|
|
77
|
+
if self._smtp_socket:
|
|
78
|
+
try:
|
|
79
|
+
self._smtp_socket.close()
|
|
80
|
+
except:
|
|
81
|
+
pass
|
|
82
|
+
if self._smtp_thread:
|
|
83
|
+
self._smtp_thread.join(timeout=2)
|
|
84
|
+
|
|
85
|
+
# Stop IMAP server
|
|
86
|
+
if self._imap_socket:
|
|
87
|
+
try:
|
|
88
|
+
self._imap_socket.close()
|
|
89
|
+
except:
|
|
90
|
+
pass
|
|
91
|
+
if self._imap_thread:
|
|
92
|
+
self._imap_thread.join(timeout=2)
|
|
93
|
+
|
|
94
|
+
print(f"[EmailMockServer] Stopped all protocols")
|
|
95
|
+
|
|
96
|
+
def clear(self):
|
|
97
|
+
"""Clear all captured emails."""
|
|
98
|
+
self.emails.clear()
|
|
99
|
+
print(f"[EmailMockServer] Cleared all emails")
|
|
100
|
+
|
|
101
|
+
def get_emails(self) -> List[Dict[str, Any]]:
|
|
102
|
+
"""Get all captured emails."""
|
|
103
|
+
return self.emails.copy()
|
|
104
|
+
|
|
105
|
+
def get_email(self, index: int) -> Optional[Dict[str, Any]]:
|
|
106
|
+
"""Get a specific email by index."""
|
|
107
|
+
if 0 <= index < len(self.emails):
|
|
108
|
+
return self.emails[index]
|
|
109
|
+
return None
|
|
110
|
+
|
|
111
|
+
def count_emails(self) -> int:
|
|
112
|
+
"""Get the count of captured emails."""
|
|
113
|
+
return len(self.emails)
|
|
114
|
+
|
|
115
|
+
def find_emails(self, **filters) -> List[Dict[str, Any]]:
|
|
116
|
+
"""
|
|
117
|
+
Find emails matching the given filters.
|
|
118
|
+
|
|
119
|
+
Example:
|
|
120
|
+
find_emails(to="user@example.com", subject="Welcome")
|
|
121
|
+
"""
|
|
122
|
+
results = []
|
|
123
|
+
for email_data in self.emails:
|
|
124
|
+
match = True
|
|
125
|
+
for key, value in filters.items():
|
|
126
|
+
if key not in email_data or email_data[key] != value:
|
|
127
|
+
match = False
|
|
128
|
+
break
|
|
129
|
+
if match:
|
|
130
|
+
results.append(email_data)
|
|
131
|
+
return results
|
|
132
|
+
|
|
133
|
+
def _run_smtp_server(self):
|
|
134
|
+
"""Run a simple SMTP server to receive emails."""
|
|
135
|
+
self._smtp_socket = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
|
|
136
|
+
self._smtp_socket.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
|
|
137
|
+
self._smtp_socket.bind((self.host, self.smtp_port))
|
|
138
|
+
self._smtp_socket.listen(5)
|
|
139
|
+
self._smtp_socket.settimeout(1.0)
|
|
140
|
+
|
|
141
|
+
while self._running:
|
|
142
|
+
try:
|
|
143
|
+
client_socket, addr = self._smtp_socket.accept()
|
|
144
|
+
threading.Thread(target=self._handle_smtp_client, args=(client_socket,), daemon=True).start()
|
|
145
|
+
except socket.timeout:
|
|
146
|
+
continue
|
|
147
|
+
except Exception as e:
|
|
148
|
+
if self._running:
|
|
149
|
+
print(f"[EmailMockServer] SMTP error: {e}")
|
|
150
|
+
break
|
|
151
|
+
|
|
152
|
+
def _handle_smtp_client(self, client_socket: socket.socket):
|
|
153
|
+
"""Handle SMTP client connection."""
|
|
154
|
+
try:
|
|
155
|
+
client_socket.sendall(b"220 Mock SMTP Server Ready\r\n")
|
|
156
|
+
|
|
157
|
+
mail_from = None
|
|
158
|
+
rcpt_to = []
|
|
159
|
+
data_mode = False
|
|
160
|
+
email_data = []
|
|
161
|
+
|
|
162
|
+
while True:
|
|
163
|
+
data = client_socket.recv(4096).decode('utf-8', errors='ignore')
|
|
164
|
+
if not data:
|
|
165
|
+
break
|
|
166
|
+
|
|
167
|
+
lines = data.strip().split('\r\n')
|
|
168
|
+
for line in lines:
|
|
169
|
+
if not line:
|
|
170
|
+
continue
|
|
171
|
+
|
|
172
|
+
if data_mode:
|
|
173
|
+
if line == '.':
|
|
174
|
+
# End of email data
|
|
175
|
+
data_mode = False
|
|
176
|
+
email_content = '\r\n'.join(email_data)
|
|
177
|
+
self._store_smtp_email(mail_from, rcpt_to, email_content)
|
|
178
|
+
client_socket.sendall(b"250 OK: Message accepted\r\n")
|
|
179
|
+
email_data = []
|
|
180
|
+
mail_from = None
|
|
181
|
+
rcpt_to = []
|
|
182
|
+
else:
|
|
183
|
+
email_data.append(line)
|
|
184
|
+
elif line.upper().startswith('EHLO') or line.upper().startswith('HELO'):
|
|
185
|
+
client_socket.sendall(b"250 Hello\r\n")
|
|
186
|
+
elif line.upper().startswith('MAIL FROM:'):
|
|
187
|
+
mail_from = line[10:].strip().strip('<>')
|
|
188
|
+
client_socket.sendall(b"250 OK\r\n")
|
|
189
|
+
elif line.upper().startswith('RCPT TO:'):
|
|
190
|
+
rcpt_to.append(line[8:].strip().strip('<>'))
|
|
191
|
+
client_socket.sendall(b"250 OK\r\n")
|
|
192
|
+
elif line.upper() == 'DATA':
|
|
193
|
+
data_mode = True
|
|
194
|
+
client_socket.sendall(b"354 Start mail input; end with <CRLF>.<CRLF>\r\n")
|
|
195
|
+
elif line.upper() == 'QUIT':
|
|
196
|
+
client_socket.sendall(b"221 Bye\r\n")
|
|
197
|
+
return
|
|
198
|
+
else:
|
|
199
|
+
client_socket.sendall(b"250 OK\r\n")
|
|
200
|
+
except Exception as e:
|
|
201
|
+
print(f"[EmailMockServer] SMTP client error: {e}")
|
|
202
|
+
finally:
|
|
203
|
+
client_socket.close()
|
|
204
|
+
|
|
205
|
+
def _store_smtp_email(self, mail_from: str, rcpt_to: List[str], raw_content: str):
|
|
206
|
+
"""Parse and store an SMTP email."""
|
|
207
|
+
try:
|
|
208
|
+
msg = email.message_from_string(raw_content)
|
|
209
|
+
|
|
210
|
+
# Extract email details
|
|
211
|
+
subject = msg.get('Subject', '')
|
|
212
|
+
to = msg.get('To', ', '.join(rcpt_to) if rcpt_to else '')
|
|
213
|
+
from_addr = msg.get('From', mail_from or '')
|
|
214
|
+
|
|
215
|
+
# Get body
|
|
216
|
+
body = ""
|
|
217
|
+
if msg.is_multipart():
|
|
218
|
+
for part in msg.walk():
|
|
219
|
+
if part.get_content_type() == "text/plain":
|
|
220
|
+
body = part.get_payload(decode=True).decode('utf-8', errors='ignore')
|
|
221
|
+
break
|
|
222
|
+
else:
|
|
223
|
+
body = msg.get_payload(decode=True)
|
|
224
|
+
if body:
|
|
225
|
+
body = body.decode('utf-8', errors='ignore')
|
|
226
|
+
else:
|
|
227
|
+
body = msg.get_payload()
|
|
228
|
+
|
|
229
|
+
email_data = {
|
|
230
|
+
"index": len(self.emails),
|
|
231
|
+
"uid": self._next_uid,
|
|
232
|
+
"to": to,
|
|
233
|
+
"from": from_addr,
|
|
234
|
+
"subject": subject,
|
|
235
|
+
"body": body,
|
|
236
|
+
"raw": raw_content,
|
|
237
|
+
"received_at": datetime.utcnow().isoformat(),
|
|
238
|
+
"flags": []
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
self._next_uid += 1
|
|
242
|
+
self.emails.append(email_data)
|
|
243
|
+
|
|
244
|
+
print(f"[EmailMockServer] SMTP captured email #{email_data['index']}: "
|
|
245
|
+
f"from={from_addr}, to={to}, subject={subject}")
|
|
246
|
+
except Exception as e:
|
|
247
|
+
print(f"[EmailMockServer] Error parsing SMTP email: {e}")
|
|
248
|
+
|
|
249
|
+
def _run_imap_server(self):
|
|
250
|
+
"""Run a simple IMAP server to allow checking emails."""
|
|
251
|
+
self._imap_socket = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
|
|
252
|
+
self._imap_socket.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
|
|
253
|
+
self._imap_socket.bind((self.host, self.imap_port))
|
|
254
|
+
self._imap_socket.listen(5)
|
|
255
|
+
self._imap_socket.settimeout(1.0)
|
|
256
|
+
|
|
257
|
+
while self._running:
|
|
258
|
+
try:
|
|
259
|
+
client_socket, addr = self._imap_socket.accept()
|
|
260
|
+
threading.Thread(target=self._handle_imap_client, args=(client_socket,), daemon=True).start()
|
|
261
|
+
except socket.timeout:
|
|
262
|
+
continue
|
|
263
|
+
except Exception as e:
|
|
264
|
+
if self._running:
|
|
265
|
+
print(f"[EmailMockServer] IMAP error: {e}")
|
|
266
|
+
break
|
|
267
|
+
|
|
268
|
+
def _handle_imap_client(self, client_socket: socket.socket):
|
|
269
|
+
"""Handle IMAP client connection."""
|
|
270
|
+
try:
|
|
271
|
+
client_socket.sendall(b"* OK Mock IMAP Server Ready\r\n")
|
|
272
|
+
|
|
273
|
+
authenticated = False
|
|
274
|
+
selected_mailbox = None
|
|
275
|
+
|
|
276
|
+
while True:
|
|
277
|
+
data = client_socket.recv(4096).decode('utf-8', errors='ignore')
|
|
278
|
+
if not data:
|
|
279
|
+
break
|
|
280
|
+
|
|
281
|
+
lines = data.strip().split('\r\n')
|
|
282
|
+
for line in lines:
|
|
283
|
+
if not line:
|
|
284
|
+
continue
|
|
285
|
+
|
|
286
|
+
parts = line.split(' ', 2)
|
|
287
|
+
if len(parts) < 2:
|
|
288
|
+
continue
|
|
289
|
+
|
|
290
|
+
tag = parts[0]
|
|
291
|
+
command = parts[1].upper()
|
|
292
|
+
args = parts[2] if len(parts) > 2 else ""
|
|
293
|
+
|
|
294
|
+
if command == 'CAPABILITY':
|
|
295
|
+
client_socket.sendall(b"* CAPABILITY IMAP4rev1\r\n")
|
|
296
|
+
client_socket.sendall(f"{tag} OK CAPABILITY completed\r\n".encode())
|
|
297
|
+
|
|
298
|
+
elif command == 'LOGIN':
|
|
299
|
+
authenticated = True
|
|
300
|
+
client_socket.sendall(f"{tag} OK LOGIN completed\r\n".encode())
|
|
301
|
+
|
|
302
|
+
elif command == 'SELECT' or command == 'EXAMINE':
|
|
303
|
+
if not authenticated:
|
|
304
|
+
client_socket.sendall(f"{tag} NO Not authenticated\r\n".encode())
|
|
305
|
+
continue
|
|
306
|
+
|
|
307
|
+
selected_mailbox = "INBOX"
|
|
308
|
+
count = len(self.emails)
|
|
309
|
+
client_socket.sendall(f"* {count} EXISTS\r\n".encode())
|
|
310
|
+
client_socket.sendall(b"* 0 RECENT\r\n")
|
|
311
|
+
client_socket.sendall(b"* FLAGS (\\Seen \\Answered \\Flagged \\Deleted \\Draft)\r\n")
|
|
312
|
+
client_socket.sendall(f"{tag} OK [{command}] completed\r\n".encode())
|
|
313
|
+
|
|
314
|
+
elif command == 'FETCH':
|
|
315
|
+
if not authenticated or not selected_mailbox:
|
|
316
|
+
client_socket.sendall(f"{tag} NO Not authenticated or no mailbox selected\r\n".encode())
|
|
317
|
+
continue
|
|
318
|
+
|
|
319
|
+
# Parse FETCH command: e.g., "1:* (FLAGS BODY[])"
|
|
320
|
+
fetch_parts = args.split(' ', 1)
|
|
321
|
+
sequence = fetch_parts[0]
|
|
322
|
+
items = fetch_parts[1] if len(fetch_parts) > 1 else "(FLAGS)"
|
|
323
|
+
|
|
324
|
+
# Simple implementation: fetch all emails
|
|
325
|
+
for i, email_data in enumerate(self.emails, 1):
|
|
326
|
+
response_items = []
|
|
327
|
+
|
|
328
|
+
if 'FLAGS' in items:
|
|
329
|
+
flags = ' '.join(email_data.get('flags', []))
|
|
330
|
+
response_items.append(f"FLAGS ({flags})")
|
|
331
|
+
|
|
332
|
+
if 'BODY[]' in items or 'RFC822' in items:
|
|
333
|
+
raw = email_data.get('raw', '')
|
|
334
|
+
if not raw:
|
|
335
|
+
# Construct simple email if raw not available
|
|
336
|
+
raw = f"From: {email_data.get('from', '')}\r\n"
|
|
337
|
+
raw += f"To: {email_data.get('to', '')}\r\n"
|
|
338
|
+
raw += f"Subject: {email_data.get('subject', '')}\r\n"
|
|
339
|
+
raw += f"\r\n{email_data.get('body', '')}"
|
|
340
|
+
|
|
341
|
+
response_items.append(f"BODY[] {{{len(raw.encode())}}}\r\n{raw}")
|
|
342
|
+
|
|
343
|
+
if 'BODY[HEADER.FIELDS (SUBJECT)]' in items or 'ENVELOPE' in items:
|
|
344
|
+
subject = email_data.get('subject', '')
|
|
345
|
+
response_items.append(f"BODY[HEADER.FIELDS (SUBJECT)] {{Subject: {subject}}}")
|
|
346
|
+
|
|
347
|
+
response = f"* {i} FETCH ({' '.join(response_items)})\r\n"
|
|
348
|
+
client_socket.sendall(response.encode())
|
|
349
|
+
|
|
350
|
+
client_socket.sendall(f"{tag} OK FETCH completed\r\n".encode())
|
|
351
|
+
|
|
352
|
+
elif command == 'SEARCH':
|
|
353
|
+
if not authenticated or not selected_mailbox:
|
|
354
|
+
client_socket.sendall(f"{tag} NO Not authenticated or no mailbox selected\r\n".encode())
|
|
355
|
+
continue
|
|
356
|
+
|
|
357
|
+
# Simple SEARCH implementation - return all emails
|
|
358
|
+
matches = ' '.join(str(i) for i in range(1, len(self.emails) + 1))
|
|
359
|
+
client_socket.sendall(f"* SEARCH {matches}\r\n".encode())
|
|
360
|
+
client_socket.sendall(f"{tag} OK SEARCH completed\r\n".encode())
|
|
361
|
+
|
|
362
|
+
elif command == 'LOGOUT':
|
|
363
|
+
client_socket.sendall(b"* BYE Logging out\r\n")
|
|
364
|
+
client_socket.sendall(f"{tag} OK LOGOUT completed\r\n".encode())
|
|
365
|
+
return
|
|
366
|
+
|
|
367
|
+
else:
|
|
368
|
+
client_socket.sendall(f"{tag} BAD Command not recognized\r\n".encode())
|
|
369
|
+
|
|
370
|
+
except Exception as e:
|
|
371
|
+
print(f"[EmailMockServer] IMAP client error: {e}")
|
|
372
|
+
finally:
|
|
373
|
+
client_socket.close()
|
|
374
|
+
|
|
375
|
+
def _create_http_handler(self):
|
|
376
|
+
"""Create the HTTP request handler with access to this server instance."""
|
|
377
|
+
server_instance = self
|
|
378
|
+
|
|
379
|
+
class EmailMockHandler(BaseHTTPRequestHandler):
|
|
380
|
+
def log_message(self, format, *args):
|
|
381
|
+
"""Suppress default logging."""
|
|
382
|
+
pass
|
|
383
|
+
|
|
384
|
+
def _send_json_response(self, status: int, data: Any):
|
|
385
|
+
"""Send a JSON response."""
|
|
386
|
+
self.send_response(status)
|
|
387
|
+
self.send_header("Content-Type", "application/json")
|
|
388
|
+
self.send_header("Access-Control-Allow-Origin", "*")
|
|
389
|
+
self.end_headers()
|
|
390
|
+
self.wfile.write(json.dumps(data).encode())
|
|
391
|
+
|
|
392
|
+
def do_OPTIONS(self):
|
|
393
|
+
"""Handle CORS preflight requests."""
|
|
394
|
+
self.send_response(200)
|
|
395
|
+
self.send_header("Access-Control-Allow-Origin", "*")
|
|
396
|
+
self.send_header("Access-Control-Allow-Methods", "GET, POST, DELETE, OPTIONS")
|
|
397
|
+
self.send_header("Access-Control-Allow-Headers", "Content-Type")
|
|
398
|
+
self.end_headers()
|
|
399
|
+
|
|
400
|
+
def do_POST(self):
|
|
401
|
+
"""Handle POST requests to capture emails."""
|
|
402
|
+
if self.path == "/send":
|
|
403
|
+
content_length = int(self.headers.get("Content-Length", 0))
|
|
404
|
+
body = self.rfile.read(content_length).decode("utf-8")
|
|
405
|
+
|
|
406
|
+
try:
|
|
407
|
+
email_data = json.loads(body)
|
|
408
|
+
email_data["received_at"] = datetime.utcnow().isoformat()
|
|
409
|
+
email_data["index"] = len(server_instance.emails)
|
|
410
|
+
server_instance.emails.append(email_data)
|
|
411
|
+
|
|
412
|
+
print(f"[EmailMockServer] Captured email #{email_data['index']}: "
|
|
413
|
+
f"to={email_data.get('to', 'N/A')}, "
|
|
414
|
+
f"subject={email_data.get('subject', 'N/A')}")
|
|
415
|
+
|
|
416
|
+
self._send_json_response(200, {
|
|
417
|
+
"status": "success",
|
|
418
|
+
"message": "Email captured",
|
|
419
|
+
"index": email_data["index"]
|
|
420
|
+
})
|
|
421
|
+
except json.JSONDecodeError:
|
|
422
|
+
self._send_json_response(400, {
|
|
423
|
+
"status": "error",
|
|
424
|
+
"message": "Invalid JSON"
|
|
425
|
+
})
|
|
426
|
+
else:
|
|
427
|
+
self._send_json_response(404, {
|
|
428
|
+
"status": "error",
|
|
429
|
+
"message": "Not found"
|
|
430
|
+
})
|
|
431
|
+
|
|
432
|
+
def do_GET(self):
|
|
433
|
+
"""Handle GET requests to query emails."""
|
|
434
|
+
if self.path == "/emails":
|
|
435
|
+
self._send_json_response(200, {
|
|
436
|
+
"count": len(server_instance.emails),
|
|
437
|
+
"emails": server_instance.emails
|
|
438
|
+
})
|
|
439
|
+
elif self.path.startswith("/emails/"):
|
|
440
|
+
try:
|
|
441
|
+
index = int(self.path.split("/")[-1])
|
|
442
|
+
email = server_instance.get_email(index)
|
|
443
|
+
if email:
|
|
444
|
+
self._send_json_response(200, email)
|
|
445
|
+
else:
|
|
446
|
+
self._send_json_response(404, {
|
|
447
|
+
"status": "error",
|
|
448
|
+
"message": f"Email at index {index} not found"
|
|
449
|
+
})
|
|
450
|
+
except ValueError:
|
|
451
|
+
self._send_json_response(400, {
|
|
452
|
+
"status": "error",
|
|
453
|
+
"message": "Invalid index"
|
|
454
|
+
})
|
|
455
|
+
elif self.path == "/health":
|
|
456
|
+
self._send_json_response(200, {
|
|
457
|
+
"status": "healthy",
|
|
458
|
+
"emails_count": len(server_instance.emails)
|
|
459
|
+
})
|
|
460
|
+
else:
|
|
461
|
+
self._send_json_response(404, {
|
|
462
|
+
"status": "error",
|
|
463
|
+
"message": "Not found"
|
|
464
|
+
})
|
|
465
|
+
|
|
466
|
+
def do_DELETE(self):
|
|
467
|
+
"""Handle DELETE requests to clear emails."""
|
|
468
|
+
if self.path == "/emails":
|
|
469
|
+
server_instance.clear()
|
|
470
|
+
self._send_json_response(200, {
|
|
471
|
+
"status": "success",
|
|
472
|
+
"message": "All emails cleared"
|
|
473
|
+
})
|
|
474
|
+
else:
|
|
475
|
+
self._send_json_response(404, {
|
|
476
|
+
"status": "error",
|
|
477
|
+
"message": "Not found"
|
|
478
|
+
})
|
|
479
|
+
|
|
480
|
+
return EmailMockHandler
|
|
@@ -0,0 +1,309 @@
|
|
|
1
|
+
import threading
|
|
2
|
+
import json
|
|
3
|
+
import re
|
|
4
|
+
from http.server import HTTPServer, BaseHTTPRequestHandler
|
|
5
|
+
from typing import List, Dict, Any, Optional, Callable
|
|
6
|
+
from datetime import datetime
|
|
7
|
+
from urllib.parse import urlparse, parse_qs
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class JsonMockServer:
|
|
11
|
+
"""
|
|
12
|
+
Simple mock JSON/REST API server that can mock endpoints and record requests.
|
|
13
|
+
|
|
14
|
+
Features:
|
|
15
|
+
- Configure mock endpoints with expected responses
|
|
16
|
+
- Record all incoming requests for verification
|
|
17
|
+
- Query recorded requests
|
|
18
|
+
- Support for dynamic response functions
|
|
19
|
+
- Pattern matching for URLs
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
def __init__(self, host: str = "localhost", port: int = 8080):
|
|
23
|
+
self.host = host
|
|
24
|
+
self.port = port
|
|
25
|
+
self.mocks: List[Dict[str, Any]] = []
|
|
26
|
+
self.requests: List[Dict[str, Any]] = []
|
|
27
|
+
self._server: Optional[HTTPServer] = None
|
|
28
|
+
self._thread: Optional[threading.Thread] = None
|
|
29
|
+
self._running = False
|
|
30
|
+
|
|
31
|
+
def start(self):
|
|
32
|
+
"""Start the mock JSON server in a background thread."""
|
|
33
|
+
if self._running:
|
|
34
|
+
print(f"[JsonMockServer] Already running on {self.host}:{self.port}")
|
|
35
|
+
return
|
|
36
|
+
|
|
37
|
+
handler = self._create_handler()
|
|
38
|
+
self._server = HTTPServer((self.host, self.port), handler)
|
|
39
|
+
self._running = True
|
|
40
|
+
|
|
41
|
+
self._thread = threading.Thread(target=self._server.serve_forever, daemon=True)
|
|
42
|
+
self._thread.start()
|
|
43
|
+
print(f"[JsonMockServer] Started on http://{self.host}:{self.port}")
|
|
44
|
+
|
|
45
|
+
def stop(self):
|
|
46
|
+
"""Stop the mock JSON server."""
|
|
47
|
+
if not self._running:
|
|
48
|
+
return
|
|
49
|
+
|
|
50
|
+
self._running = False
|
|
51
|
+
if self._server:
|
|
52
|
+
self._server.shutdown()
|
|
53
|
+
self._server.server_close()
|
|
54
|
+
if self._thread:
|
|
55
|
+
self._thread.join(timeout=2)
|
|
56
|
+
print(f"[JsonMockServer] Stopped")
|
|
57
|
+
|
|
58
|
+
def clear_mocks(self):
|
|
59
|
+
"""Clear all configured mocks."""
|
|
60
|
+
self.mocks.clear()
|
|
61
|
+
print(f"[JsonMockServer] Cleared all mocks")
|
|
62
|
+
|
|
63
|
+
def clear_requests(self):
|
|
64
|
+
"""Clear all recorded requests."""
|
|
65
|
+
self.requests.clear()
|
|
66
|
+
print(f"[JsonMockServer] Cleared all recorded requests")
|
|
67
|
+
|
|
68
|
+
def clear_all(self):
|
|
69
|
+
"""Clear both mocks and recorded requests."""
|
|
70
|
+
self.clear_mocks()
|
|
71
|
+
self.clear_requests()
|
|
72
|
+
|
|
73
|
+
def add_mock(
|
|
74
|
+
self,
|
|
75
|
+
method: str,
|
|
76
|
+
path: str,
|
|
77
|
+
response_body: Any = None,
|
|
78
|
+
status_code: int = 200,
|
|
79
|
+
response_headers: Optional[Dict[str, str]] = None,
|
|
80
|
+
response_fn: Optional[Callable] = None,
|
|
81
|
+
is_regex: bool = False
|
|
82
|
+
):
|
|
83
|
+
"""
|
|
84
|
+
Add a mock endpoint.
|
|
85
|
+
|
|
86
|
+
Args:
|
|
87
|
+
method: HTTP method (GET, POST, PUT, DELETE, etc.)
|
|
88
|
+
path: URL path or regex pattern
|
|
89
|
+
response_body: Response body to return (dict or any JSON-serializable)
|
|
90
|
+
status_code: HTTP status code to return
|
|
91
|
+
response_headers: Additional response headers
|
|
92
|
+
response_fn: Optional function(request_data) -> response_body for dynamic responses
|
|
93
|
+
is_regex: Whether path is a regex pattern
|
|
94
|
+
|
|
95
|
+
Example:
|
|
96
|
+
server.add_mock("GET", "/api/users", {"users": []})
|
|
97
|
+
server.add_mock("POST", "/api/users", {"id": 1}, status_code=201)
|
|
98
|
+
server.add_mock("GET", r"/api/users/\d+", response_fn=lambda req: {"id": req["path_params"][0]}, is_regex=True)
|
|
99
|
+
"""
|
|
100
|
+
mock = {
|
|
101
|
+
"method": method.upper(),
|
|
102
|
+
"path": path,
|
|
103
|
+
"response_body": response_body,
|
|
104
|
+
"status_code": status_code,
|
|
105
|
+
"response_headers": response_headers or {},
|
|
106
|
+
"response_fn": response_fn,
|
|
107
|
+
"is_regex": is_regex,
|
|
108
|
+
"hit_count": 0
|
|
109
|
+
}
|
|
110
|
+
self.mocks.append(mock)
|
|
111
|
+
print(f"[JsonMockServer] Added mock: {method.upper()} {path} -> {status_code}")
|
|
112
|
+
|
|
113
|
+
def get_requests(self) -> List[Dict[str, Any]]:
|
|
114
|
+
"""Get all recorded requests."""
|
|
115
|
+
return self.requests.copy()
|
|
116
|
+
|
|
117
|
+
def get_request(self, index: int) -> Optional[Dict[str, Any]]:
|
|
118
|
+
"""Get a specific request by index."""
|
|
119
|
+
if 0 <= index < len(self.requests):
|
|
120
|
+
return self.requests[index]
|
|
121
|
+
return None
|
|
122
|
+
|
|
123
|
+
def count_requests(self, method: Optional[str] = None, path: Optional[str] = None) -> int:
|
|
124
|
+
"""
|
|
125
|
+
Count recorded requests, optionally filtered by method and/or path.
|
|
126
|
+
|
|
127
|
+
Example:
|
|
128
|
+
count_requests() # all requests
|
|
129
|
+
count_requests(method="POST")
|
|
130
|
+
count_requests(path="/api/users")
|
|
131
|
+
count_requests(method="POST", path="/api/users")
|
|
132
|
+
"""
|
|
133
|
+
filtered = self.requests
|
|
134
|
+
if method:
|
|
135
|
+
filtered = [r for r in filtered if r["method"] == method.upper()]
|
|
136
|
+
if path:
|
|
137
|
+
filtered = [r for r in filtered if r["path"] == path]
|
|
138
|
+
return len(filtered)
|
|
139
|
+
|
|
140
|
+
def find_requests(self, **filters) -> List[Dict[str, Any]]:
|
|
141
|
+
"""
|
|
142
|
+
Find requests matching the given filters.
|
|
143
|
+
|
|
144
|
+
Example:
|
|
145
|
+
find_requests(method="POST", path="/api/users")
|
|
146
|
+
"""
|
|
147
|
+
results = []
|
|
148
|
+
for request in self.requests:
|
|
149
|
+
match = True
|
|
150
|
+
for key, value in filters.items():
|
|
151
|
+
if key not in request or request[key] != value:
|
|
152
|
+
match = False
|
|
153
|
+
break
|
|
154
|
+
if match:
|
|
155
|
+
results.append(request)
|
|
156
|
+
return results
|
|
157
|
+
|
|
158
|
+
def _find_mock(self, method: str, path: str) -> Optional[Dict[str, Any]]:
|
|
159
|
+
"""Find a matching mock for the given method and path."""
|
|
160
|
+
for mock in self.mocks:
|
|
161
|
+
if mock["method"] != method:
|
|
162
|
+
continue
|
|
163
|
+
|
|
164
|
+
if mock["is_regex"]:
|
|
165
|
+
if re.match(mock["path"], path):
|
|
166
|
+
return mock
|
|
167
|
+
else:
|
|
168
|
+
if mock["path"] == path:
|
|
169
|
+
return mock
|
|
170
|
+
return None
|
|
171
|
+
|
|
172
|
+
def _create_handler(self):
|
|
173
|
+
"""Create the HTTP request handler with access to this server instance."""
|
|
174
|
+
server_instance = self
|
|
175
|
+
|
|
176
|
+
class JsonMockHandler(BaseHTTPRequestHandler):
|
|
177
|
+
def log_message(self, format, *args):
|
|
178
|
+
"""Suppress default logging."""
|
|
179
|
+
pass
|
|
180
|
+
|
|
181
|
+
def _send_json_response(self, status: int, data: Any, headers: Optional[Dict[str, str]] = None):
|
|
182
|
+
"""Send a JSON response."""
|
|
183
|
+
self.send_response(status)
|
|
184
|
+
self.send_header("Content-Type", "application/json")
|
|
185
|
+
self.send_header("Access-Control-Allow-Origin", "*")
|
|
186
|
+
if headers:
|
|
187
|
+
for key, value in headers.items():
|
|
188
|
+
self.send_header(key, value)
|
|
189
|
+
self.end_headers()
|
|
190
|
+
self.wfile.write(json.dumps(data).encode())
|
|
191
|
+
|
|
192
|
+
def do_OPTIONS(self):
|
|
193
|
+
"""Handle CORS preflight requests."""
|
|
194
|
+
self.send_response(200)
|
|
195
|
+
self.send_header("Access-Control-Allow-Origin", "*")
|
|
196
|
+
self.send_header("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, PATCH, OPTIONS")
|
|
197
|
+
self.send_header("Access-Control-Allow-Headers", "Content-Type, Authorization")
|
|
198
|
+
self.end_headers()
|
|
199
|
+
|
|
200
|
+
def _handle_request(self, method: str):
|
|
201
|
+
"""Common request handling logic."""
|
|
202
|
+
parsed_url = urlparse(self.path)
|
|
203
|
+
path = parsed_url.path
|
|
204
|
+
query_params = parse_qs(parsed_url.query)
|
|
205
|
+
|
|
206
|
+
# Read request body if present
|
|
207
|
+
body = None
|
|
208
|
+
content_length = int(self.headers.get("Content-Length", 0))
|
|
209
|
+
if content_length > 0:
|
|
210
|
+
body_bytes = self.rfile.read(content_length)
|
|
211
|
+
try:
|
|
212
|
+
body = json.loads(body_bytes.decode("utf-8"))
|
|
213
|
+
except json.JSONDecodeError:
|
|
214
|
+
body = body_bytes.decode("utf-8")
|
|
215
|
+
|
|
216
|
+
# Record the request
|
|
217
|
+
request_data = {
|
|
218
|
+
"index": len(server_instance.requests),
|
|
219
|
+
"method": method,
|
|
220
|
+
"path": path,
|
|
221
|
+
"query_params": query_params,
|
|
222
|
+
"headers": dict(self.headers),
|
|
223
|
+
"body": body,
|
|
224
|
+
"received_at": datetime.utcnow().isoformat()
|
|
225
|
+
}
|
|
226
|
+
server_instance.requests.append(request_data)
|
|
227
|
+
|
|
228
|
+
print(f"[JsonMockServer] Recorded request #{request_data['index']}: {method} {path}")
|
|
229
|
+
|
|
230
|
+
# Special endpoint to query recorded requests
|
|
231
|
+
if path == "/_mock/requests":
|
|
232
|
+
self._send_json_response(200, {
|
|
233
|
+
"count": len(server_instance.requests),
|
|
234
|
+
"requests": server_instance.requests
|
|
235
|
+
})
|
|
236
|
+
return
|
|
237
|
+
elif path.startswith("/_mock/requests/"):
|
|
238
|
+
try:
|
|
239
|
+
index = int(path.split("/")[-1])
|
|
240
|
+
request = server_instance.get_request(index)
|
|
241
|
+
if request:
|
|
242
|
+
self._send_json_response(200, request)
|
|
243
|
+
else:
|
|
244
|
+
self._send_json_response(404, {
|
|
245
|
+
"status": "error",
|
|
246
|
+
"message": f"Request at index {index} not found"
|
|
247
|
+
})
|
|
248
|
+
return
|
|
249
|
+
except ValueError:
|
|
250
|
+
self._send_json_response(400, {
|
|
251
|
+
"status": "error",
|
|
252
|
+
"message": "Invalid index"
|
|
253
|
+
})
|
|
254
|
+
return
|
|
255
|
+
elif path == "/_mock/health":
|
|
256
|
+
self._send_json_response(200, {
|
|
257
|
+
"status": "healthy",
|
|
258
|
+
"mocks_count": len(server_instance.mocks),
|
|
259
|
+
"requests_count": len(server_instance.requests)
|
|
260
|
+
})
|
|
261
|
+
return
|
|
262
|
+
|
|
263
|
+
# Find matching mock
|
|
264
|
+
mock = server_instance._find_mock(method, path)
|
|
265
|
+
if mock:
|
|
266
|
+
mock["hit_count"] += 1
|
|
267
|
+
|
|
268
|
+
# Generate response
|
|
269
|
+
if mock["response_fn"]:
|
|
270
|
+
try:
|
|
271
|
+
response_body = mock["response_fn"](request_data)
|
|
272
|
+
except Exception as e:
|
|
273
|
+
self._send_json_response(500, {
|
|
274
|
+
"status": "error",
|
|
275
|
+
"message": f"Mock response function error: {str(e)}"
|
|
276
|
+
})
|
|
277
|
+
return
|
|
278
|
+
else:
|
|
279
|
+
response_body = mock["response_body"]
|
|
280
|
+
|
|
281
|
+
print(f"[JsonMockServer] Matched mock: {method} {path} -> {mock['status_code']}")
|
|
282
|
+
self._send_json_response(
|
|
283
|
+
mock["status_code"],
|
|
284
|
+
response_body,
|
|
285
|
+
mock["response_headers"]
|
|
286
|
+
)
|
|
287
|
+
else:
|
|
288
|
+
print(f"[JsonMockServer] No mock found for: {method} {path}")
|
|
289
|
+
self._send_json_response(404, {
|
|
290
|
+
"status": "error",
|
|
291
|
+
"message": f"No mock configured for {method} {path}"
|
|
292
|
+
})
|
|
293
|
+
|
|
294
|
+
def do_GET(self):
|
|
295
|
+
self._handle_request("GET")
|
|
296
|
+
|
|
297
|
+
def do_POST(self):
|
|
298
|
+
self._handle_request("POST")
|
|
299
|
+
|
|
300
|
+
def do_PUT(self):
|
|
301
|
+
self._handle_request("PUT")
|
|
302
|
+
|
|
303
|
+
def do_DELETE(self):
|
|
304
|
+
self._handle_request("DELETE")
|
|
305
|
+
|
|
306
|
+
def do_PATCH(self):
|
|
307
|
+
self._handle_request("PATCH")
|
|
308
|
+
|
|
309
|
+
return JsonMockHandler
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
sefrone_api_e2e/__init__.py,sha256=qXaEtQP7TY_yTKMOQeLhdqaRXWjFEkJeyUckK0ZR1og,232
|
|
2
|
+
sefrone_api_e2e/api_e2e_manager.py,sha256=noCjvItZqbjXZIkvvhMft7m0T85FN3afSbhKJWw9XU8,24306
|
|
3
|
+
sefrone_api_e2e/email_mock_server.py,sha256=M3tY84pL0JKD0KLi9yuj2SBEXF24z_1wM4NfD1VB4pE,21479
|
|
4
|
+
sefrone_api_e2e/json_mock_server.py,sha256=Trg2iezPyUtTsrq8PlFzBE15z8wHLZtEnJDU-QMjaW0,12530
|
|
5
|
+
sefrone_api_e2e-1.0.4.dist-info/METADATA,sha256=eM2GpmOL9noNLeq1fkvG0xVKNQe58PooYTOggL7Fc6o,721
|
|
6
|
+
sefrone_api_e2e-1.0.4.dist-info/WHEEL,sha256=tZoeGjtWxWRfdplE7E3d45VPlLNQnvbKiYnx7gwAy8A,92
|
|
7
|
+
sefrone_api_e2e-1.0.4.dist-info/top_level.txt,sha256=19WO3CsUWUiGtZBotT587N-tkxxjctKOPDEHWpHpS8M,16
|
|
8
|
+
sefrone_api_e2e-1.0.4.dist-info/RECORD,,
|
|
@@ -1,6 +0,0 @@
|
|
|
1
|
-
sefrone_api_e2e/__init__.py,sha256=NNUHigTCBkjdwCfQXVEA7urxF1-A8HF1_IuWJ8LuO-k,91
|
|
2
|
-
sefrone_api_e2e/api_e2e_manager.py,sha256=i49u0l1r_07ndG4jG1HxI5OGUvNXCqBFHiZd9WE90_c,16463
|
|
3
|
-
sefrone_api_e2e-1.0.2.dist-info/METADATA,sha256=xg-cB7oM_tGaBlHuaYGIx05MM8IUcN9mgnk8iNVRvhg,721
|
|
4
|
-
sefrone_api_e2e-1.0.2.dist-info/WHEEL,sha256=tZoeGjtWxWRfdplE7E3d45VPlLNQnvbKiYnx7gwAy8A,92
|
|
5
|
-
sefrone_api_e2e-1.0.2.dist-info/top_level.txt,sha256=19WO3CsUWUiGtZBotT587N-tkxxjctKOPDEHWpHpS8M,16
|
|
6
|
-
sefrone_api_e2e-1.0.2.dist-info/RECORD,,
|
|
File without changes
|
|
File without changes
|