sefrone-api-e2e 1.0.2__tar.gz → 1.0.4__tar.gz

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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.1
2
2
  Name: sefrone_api_e2e
3
- Version: 1.0.2
3
+ Version: 1.0.4
4
4
  Summary: A Python package to provide e2e testing helpers for sefrone API projects
5
5
  Home-page: https://bitbucket.org/sefrone/sefrone_pypi
6
6
  Author: Sefrone
@@ -0,0 +1,9 @@
1
+ from .api_e2e_manager import ApiE2ETestsManager
2
+ from .email_mock_server import EmailMockServer
3
+ from .json_mock_server import JsonMockServer
4
+
5
+ __all__ = [
6
+ "ApiE2ETestsManager",
7
+ "EmailMockServer",
8
+ "JsonMockServer"
9
+ ]
@@ -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
- parts = expr.split(".")
177
+ # Parse path with array indexing support: body.items[0].id and count(): body.items.count()
171
178
  val = ctx
172
- for p in parts:
173
- if isinstance(val, dict) and p in val:
174
- val = val[p]
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
- raise KeyError(f"Cannot resolve '{expr}' in context: {p} not found")
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} => evaluated as {left_val} {matched_op} {right_val}"
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
- try:
294
- step_start = time.time()
295
- print(f"Step: {step['name']}")
296
- url = base_url + ApiE2ETestsManager.substitute_stored(step['endpoint'], scenario_name)
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
- request_headers = {}
300
- if "request_headers" in step:
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
- resp_json = resp.json()
319
- except Exception:
320
- raise AssertionError("Response is not valid JSON")
321
-
322
- if "body" in expect:
323
- if is_verbose:
324
- print(f"Response body: {resp_json}")
325
- ApiE2ETestsManager.validate_structure(resp_json, expect["body"])
326
-
327
- if "save" in step:
328
- for key, path_expr in step["save"].items():
329
- rendered = ApiE2ETestsManager.render_template(path_expr, {"body": resp_json})
330
- ApiE2ETestsManager.store_value(key, rendered, scenario_name)
331
-
332
- if "assertions" in step:
333
- for expr in step["assertions"]:
334
- rendered = ApiE2ETestsManager.render_template(expr, {"body": resp_json}, add_quotes=True)
335
- ApiE2ETestsManager.try_eval(rendered, expr)
336
-
337
- elapsed = time.time() - step_start
338
- step_metrics.append({"name": step["name"], "time": elapsed, "result": "PASS"})
339
- print(f"[OK ] Step passed in {elapsed:.2f}s\n")
340
- except Exception as e:
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(e)})
343
- print(f"[FAIL] Step failed in {failed_elapsed:.2f}s error: {str(e)}\n")
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
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.1
2
2
  Name: sefrone-api-e2e
3
- Version: 1.0.2
3
+ Version: 1.0.4
4
4
  Summary: A Python package to provide e2e testing helpers for sefrone API projects
5
5
  Home-page: https://bitbucket.org/sefrone/sefrone_pypi
6
6
  Author: Sefrone
@@ -2,6 +2,8 @@ README.md
2
2
  setup.py
3
3
  sefrone_api_e2e/__init__.py
4
4
  sefrone_api_e2e/api_e2e_manager.py
5
+ sefrone_api_e2e/email_mock_server.py
6
+ sefrone_api_e2e/json_mock_server.py
5
7
  sefrone_api_e2e.egg-info/PKG-INFO
6
8
  sefrone_api_e2e.egg-info/SOURCES.txt
7
9
  sefrone_api_e2e.egg-info/dependency_links.txt
@@ -5,7 +5,7 @@ with open("README.md", "r", encoding="utf-8") as fh:
5
5
 
6
6
  setup(
7
7
  name="sefrone_api_e2e",
8
- version="1.0.2",
8
+ version="1.0.4",
9
9
  author="Sefrone",
10
10
  author_email="contact@sefrone.com",
11
11
  description="A Python package to provide e2e testing helpers for sefrone API projects",
@@ -1,5 +0,0 @@
1
- from .api_e2e_manager import ApiE2ETestsManager
2
-
3
- __all__ = [
4
- "ApiE2ETestsManager"
5
- ]