sefrone-api-e2e 1.0.3__tar.gz → 1.0.5__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.
- {sefrone_api_e2e-1.0.3 → sefrone_api_e2e-1.0.5}/PKG-INFO +1 -1
- sefrone_api_e2e-1.0.5/sefrone_api_e2e/__init__.py +9 -0
- {sefrone_api_e2e-1.0.3 → sefrone_api_e2e-1.0.5}/sefrone_api_e2e/api_e2e_manager.py +24 -4
- sefrone_api_e2e-1.0.5/sefrone_api_e2e/email_mock_server.py +480 -0
- sefrone_api_e2e-1.0.5/sefrone_api_e2e/json_mock_server.py +309 -0
- {sefrone_api_e2e-1.0.3 → sefrone_api_e2e-1.0.5}/sefrone_api_e2e.egg-info/PKG-INFO +1 -1
- {sefrone_api_e2e-1.0.3 → sefrone_api_e2e-1.0.5}/sefrone_api_e2e.egg-info/SOURCES.txt +2 -0
- {sefrone_api_e2e-1.0.3 → sefrone_api_e2e-1.0.5}/setup.py +1 -1
- sefrone_api_e2e-1.0.3/sefrone_api_e2e/__init__.py +0 -5
- {sefrone_api_e2e-1.0.3 → sefrone_api_e2e-1.0.5}/README.md +0 -0
- {sefrone_api_e2e-1.0.3 → sefrone_api_e2e-1.0.5}/sefrone_api_e2e.egg-info/dependency_links.txt +0 -0
- {sefrone_api_e2e-1.0.3 → sefrone_api_e2e-1.0.5}/sefrone_api_e2e.egg-info/requires.txt +0 -0
- {sefrone_api_e2e-1.0.3 → sefrone_api_e2e-1.0.5}/sefrone_api_e2e.egg-info/top_level.txt +0 -0
- {sefrone_api_e2e-1.0.3 → sefrone_api_e2e-1.0.5}/setup.cfg +0 -0
|
@@ -129,15 +129,24 @@ class ApiE2ETestsManager:
|
|
|
129
129
|
|
|
130
130
|
@staticmethod
|
|
131
131
|
def check_type(value, expected_type):
|
|
132
|
+
# Check for nullable type (e.g., "string?", "number?")
|
|
133
|
+
is_nullable = expected_type.endswith("?")
|
|
134
|
+
if is_nullable:
|
|
135
|
+
base_type = expected_type[:-1]
|
|
136
|
+
if value is None:
|
|
137
|
+
return True
|
|
138
|
+
else:
|
|
139
|
+
base_type = expected_type
|
|
140
|
+
|
|
132
141
|
type_map = {
|
|
133
142
|
"string": str,
|
|
134
143
|
"number": (int, float),
|
|
135
144
|
"boolean": bool,
|
|
136
145
|
"datetime": str
|
|
137
146
|
}
|
|
138
|
-
if
|
|
147
|
+
if base_type not in type_map:
|
|
139
148
|
raise ValueError(f"Unknown type in YAML: {expected_type}")
|
|
140
|
-
if
|
|
149
|
+
if base_type == "datetime":
|
|
141
150
|
try:
|
|
142
151
|
datetime.datetime.fromisoformat(value)
|
|
143
152
|
return True
|
|
@@ -148,7 +157,7 @@ class ApiE2ETestsManager:
|
|
|
148
157
|
return True
|
|
149
158
|
except Exception:
|
|
150
159
|
return False
|
|
151
|
-
return isinstance(value, type_map[
|
|
160
|
+
return isinstance(value, type_map[base_type])
|
|
152
161
|
|
|
153
162
|
@staticmethod
|
|
154
163
|
def validate_structure(data, expected):
|
|
@@ -427,12 +436,23 @@ class ApiE2ETestsManager:
|
|
|
427
436
|
header_value = ApiE2ETestsManager.substitute_stored(header_value, scenario_name)
|
|
428
437
|
request_headers[header_key] = header_value
|
|
429
438
|
|
|
439
|
+
request_query_params = {}
|
|
440
|
+
if "request_query_params" in step:
|
|
441
|
+
params = step["request_query_params"]
|
|
442
|
+
if isinstance(params, dict):
|
|
443
|
+
for param_key, param_value in params.items():
|
|
444
|
+
if isinstance(param_value, str):
|
|
445
|
+
param_value = ApiE2ETestsManager.substitute_stored(param_value, scenario_name)
|
|
446
|
+
request_query_params[param_key] = param_value
|
|
447
|
+
else:
|
|
448
|
+
raise ValueError(f"query_params must be a dictionary, got {type(params)}")
|
|
449
|
+
|
|
430
450
|
body = step.get("body")
|
|
431
451
|
if body is not None:
|
|
432
452
|
body = ApiE2ETestsManager.substitute_stored_in_body(body, scenario_name)
|
|
433
453
|
expect = step.get("expect", {})
|
|
434
454
|
|
|
435
|
-
resp = session.request(method, url, json=body, headers=request_headers, timeout=ApiE2ETestsManager.DEFAULT_TIMEOUT)
|
|
455
|
+
resp = session.request(method, url, json=body, headers=request_headers, params=request_query_params, timeout=ApiE2ETestsManager.DEFAULT_TIMEOUT)
|
|
436
456
|
print(f" -> {method} {url} -> {resp.status_code}")
|
|
437
457
|
|
|
438
458
|
expected_status = expect.get("status")
|
|
@@ -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
|
|
@@ -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.
|
|
8
|
+
version="1.0.5",
|
|
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",
|
|
File without changes
|
{sefrone_api_e2e-1.0.3 → sefrone_api_e2e-1.0.5}/sefrone_api_e2e.egg-info/dependency_links.txt
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|