sefrone-api-e2e 1.0.0__tar.gz → 1.0.1__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.0 → sefrone_api_e2e-1.0.1}/PKG-INFO +1 -1
- sefrone_api_e2e-1.0.1/sefrone_api_e2e/api_e2e_manager.py +384 -0
- {sefrone_api_e2e-1.0.0 → sefrone_api_e2e-1.0.1}/sefrone_api_e2e.egg-info/PKG-INFO +1 -1
- {sefrone_api_e2e-1.0.0 → sefrone_api_e2e-1.0.1}/sefrone_api_e2e.egg-info/requires.txt +1 -0
- {sefrone_api_e2e-1.0.0 → sefrone_api_e2e-1.0.1}/setup.py +2 -1
- sefrone_api_e2e-1.0.0/sefrone_api_e2e/api_e2e_manager.py +0 -168
- {sefrone_api_e2e-1.0.0 → sefrone_api_e2e-1.0.1}/README.md +0 -0
- {sefrone_api_e2e-1.0.0 → sefrone_api_e2e-1.0.1}/sefrone_api_e2e/__init__.py +0 -0
- {sefrone_api_e2e-1.0.0 → sefrone_api_e2e-1.0.1}/sefrone_api_e2e.egg-info/SOURCES.txt +0 -0
- {sefrone_api_e2e-1.0.0 → sefrone_api_e2e-1.0.1}/sefrone_api_e2e.egg-info/dependency_links.txt +0 -0
- {sefrone_api_e2e-1.0.0 → sefrone_api_e2e-1.0.1}/sefrone_api_e2e.egg-info/top_level.txt +0 -0
- {sefrone_api_e2e-1.0.0 → sefrone_api_e2e-1.0.1}/setup.cfg +0 -0
|
@@ -0,0 +1,384 @@
|
|
|
1
|
+
import yaml
|
|
2
|
+
import requests
|
|
3
|
+
import re
|
|
4
|
+
import os
|
|
5
|
+
import datetime
|
|
6
|
+
import time
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from typing import Any, Dict, List
|
|
9
|
+
|
|
10
|
+
class ApiE2ETestsManager:
|
|
11
|
+
# Precompiled patterns and defaults
|
|
12
|
+
VAR_PATTERN = re.compile(r"\$var\(([A-Za-z0-9_]+)\)")
|
|
13
|
+
STORED_PATTERN = re.compile(r"\{\$stored\.([a-zA-Z0-9_.]+)\}")
|
|
14
|
+
TEMPLATE_PATTERN = re.compile(r"\{\{\s*([^}]+?)\s*\}\}")
|
|
15
|
+
DEFAULT_TIMEOUT = 10 # seconds for HTTP requests
|
|
16
|
+
|
|
17
|
+
# Mutable state (reset at run start)
|
|
18
|
+
global_store: Dict[str, Any] = {}
|
|
19
|
+
scenario_results: List[Dict[str, Any]] = [] # holds timing + status info for all scenarios
|
|
20
|
+
|
|
21
|
+
@staticmethod
|
|
22
|
+
def run_all_tests(folder_path, env_file_path, is_verbose=False):
|
|
23
|
+
print("\nRunning API E2E tests...")
|
|
24
|
+
|
|
25
|
+
# Reset shared state to avoid leakage between runs
|
|
26
|
+
ApiE2ETestsManager.global_store = {}
|
|
27
|
+
ApiE2ETestsManager.scenario_results = []
|
|
28
|
+
|
|
29
|
+
p = Path(folder_path)
|
|
30
|
+
scenario_files = sorted([f.name for f in p.iterdir() if f.is_file() and f.suffix == ".yaml"])
|
|
31
|
+
total_start = time.time()
|
|
32
|
+
|
|
33
|
+
for filename in scenario_files:
|
|
34
|
+
yaml_file_path = os.path.join(folder_path, filename)
|
|
35
|
+
scenario_name = os.path.splitext(filename)[0]
|
|
36
|
+
|
|
37
|
+
try:
|
|
38
|
+
print(f"\n--- Running scenario: {filename} ---")
|
|
39
|
+
spec = ApiE2ETestsManager.parse_and_load_yaml(yaml_file_path, env_file_path)
|
|
40
|
+
start_time = time.time()
|
|
41
|
+
step_metrics = ApiE2ETestsManager.run_yaml_test(spec, scenario_name, is_verbose)
|
|
42
|
+
elapsed = time.time() - start_time
|
|
43
|
+
|
|
44
|
+
if all(step["result"] == "PASS" for step in step_metrics):
|
|
45
|
+
ApiE2ETestsManager.scenario_results.append({
|
|
46
|
+
"scenario": scenario_name,
|
|
47
|
+
"status": "[PASS]",
|
|
48
|
+
"steps": step_metrics,
|
|
49
|
+
"total_time": elapsed
|
|
50
|
+
})
|
|
51
|
+
else:
|
|
52
|
+
ApiE2ETestsManager.scenario_results.append({
|
|
53
|
+
"scenario": scenario_name,
|
|
54
|
+
"status": "[FAIL]",
|
|
55
|
+
"steps": step_metrics,
|
|
56
|
+
"total_time": elapsed
|
|
57
|
+
})
|
|
58
|
+
# mark remaining scenarios as skipped
|
|
59
|
+
remaining = scenario_files[scenario_files.index(filename)+1:]
|
|
60
|
+
for skipped_file in remaining:
|
|
61
|
+
skipped_name = os.path.splitext(skipped_file)[0]
|
|
62
|
+
ApiE2ETestsManager.scenario_results.append({
|
|
63
|
+
"scenario": skipped_name,
|
|
64
|
+
"status": "[SKIP]",
|
|
65
|
+
"steps": [],
|
|
66
|
+
"total_time": 0
|
|
67
|
+
})
|
|
68
|
+
break
|
|
69
|
+
|
|
70
|
+
except AssertionError:
|
|
71
|
+
# mark the failed one
|
|
72
|
+
failed_elapsed = time.time() - start_time if 'start_time' in locals() else 0
|
|
73
|
+
ApiE2ETestsManager.scenario_results.append({
|
|
74
|
+
"scenario": scenario_name,
|
|
75
|
+
"status": "[FAIL]",
|
|
76
|
+
"steps": step_metrics if 'step_metrics' in locals() else [],
|
|
77
|
+
"total_time": failed_elapsed
|
|
78
|
+
})
|
|
79
|
+
# mark remaining scenarios as skipped
|
|
80
|
+
remaining = scenario_files[scenario_files.index(filename)+1:]
|
|
81
|
+
for skipped_file in remaining:
|
|
82
|
+
skipped_name = os.path.splitext(skipped_file)[0]
|
|
83
|
+
ApiE2ETestsManager.scenario_results.append({
|
|
84
|
+
"scenario": skipped_name,
|
|
85
|
+
"status": "[SKIP]",
|
|
86
|
+
"steps": [],
|
|
87
|
+
"total_time": 0
|
|
88
|
+
})
|
|
89
|
+
break # stop all further execution
|
|
90
|
+
|
|
91
|
+
total_elapsed = time.time() - total_start
|
|
92
|
+
print("\nAPI E2E tests completed.")
|
|
93
|
+
ApiE2ETestsManager.print_summary(total_elapsed)
|
|
94
|
+
|
|
95
|
+
@staticmethod
|
|
96
|
+
def read_env_value_from_file(env_file_path, env_var):
|
|
97
|
+
try:
|
|
98
|
+
with open(env_file_path, 'r', encoding='utf-8') as file:
|
|
99
|
+
for raw in file:
|
|
100
|
+
line = raw.strip()
|
|
101
|
+
if not line or line.startswith('#'):
|
|
102
|
+
continue
|
|
103
|
+
if line.startswith(f'{env_var}='):
|
|
104
|
+
return line[len(f'{env_var}='):].strip()
|
|
105
|
+
except FileNotFoundError:
|
|
106
|
+
print(f"[WARN] Environment file not found: {env_file_path}")
|
|
107
|
+
return ""
|
|
108
|
+
print(f"[WARN] Environment variable '{env_var}' not found in {env_file_path}.")
|
|
109
|
+
return ""
|
|
110
|
+
|
|
111
|
+
@staticmethod
|
|
112
|
+
def parse_and_load_yaml(yaml_file_path, env_file_path):
|
|
113
|
+
with open(yaml_file_path, "r", encoding="utf-8") as f:
|
|
114
|
+
content = f.read()
|
|
115
|
+
|
|
116
|
+
def replacer(match):
|
|
117
|
+
key = match.group(1)
|
|
118
|
+
return ApiE2ETestsManager.read_env_value_from_file(env_file_path, key)
|
|
119
|
+
|
|
120
|
+
resolved_content = ApiE2ETestsManager.VAR_PATTERN.sub(replacer, content)
|
|
121
|
+
return yaml.safe_load(resolved_content)
|
|
122
|
+
|
|
123
|
+
@staticmethod
|
|
124
|
+
def check_type(value, expected_type):
|
|
125
|
+
type_map = {
|
|
126
|
+
"string": str,
|
|
127
|
+
"number": (int, float),
|
|
128
|
+
"boolean": bool,
|
|
129
|
+
"datetime": str
|
|
130
|
+
}
|
|
131
|
+
if expected_type not in type_map:
|
|
132
|
+
raise ValueError(f"Unknown type in YAML: {expected_type}")
|
|
133
|
+
if expected_type == "datetime":
|
|
134
|
+
try:
|
|
135
|
+
datetime.datetime.fromisoformat(value)
|
|
136
|
+
return True
|
|
137
|
+
except Exception:
|
|
138
|
+
try:
|
|
139
|
+
if re.match(r"^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{1,6}$", value):
|
|
140
|
+
datetime.datetime.strptime(value, "%Y-%m-%dT%H:%M:%S.%f")
|
|
141
|
+
return True
|
|
142
|
+
except Exception:
|
|
143
|
+
return False
|
|
144
|
+
return isinstance(value, type_map[expected_type])
|
|
145
|
+
|
|
146
|
+
@staticmethod
|
|
147
|
+
def validate_structure(data, expected):
|
|
148
|
+
if isinstance(expected, dict):
|
|
149
|
+
if not isinstance(data, dict):
|
|
150
|
+
raise AssertionError(f"Expected dict but got {type(data)}")
|
|
151
|
+
for k, v in expected.items():
|
|
152
|
+
if k not in data:
|
|
153
|
+
raise AssertionError(f"Missing key: {k} (Data keys: {list(data.keys())})")
|
|
154
|
+
ApiE2ETestsManager.validate_structure(data[k], v)
|
|
155
|
+
elif isinstance(expected, list):
|
|
156
|
+
if not isinstance(data, list):
|
|
157
|
+
raise AssertionError(f"Expected list but got {type(data)}")
|
|
158
|
+
if len(expected) > 0:
|
|
159
|
+
for item in data:
|
|
160
|
+
ApiE2ETestsManager.validate_structure(item, expected[0])
|
|
161
|
+
elif isinstance(expected, str):
|
|
162
|
+
if not ApiE2ETestsManager.check_type(data, expected):
|
|
163
|
+
raise AssertionError(f"Expected {expected}, got {data} ({type(data)})")
|
|
164
|
+
else:
|
|
165
|
+
raise ValueError(f"Invalid expected type: {expected}")
|
|
166
|
+
|
|
167
|
+
@staticmethod
|
|
168
|
+
def render_template(template_str, context, add_quotes=False):
|
|
169
|
+
def resolve_path(expr, ctx):
|
|
170
|
+
parts = expr.split(".")
|
|
171
|
+
val = ctx
|
|
172
|
+
for p in parts:
|
|
173
|
+
if isinstance(val, dict) and p in val:
|
|
174
|
+
val = val[p]
|
|
175
|
+
else:
|
|
176
|
+
raise KeyError(f"Cannot resolve '{expr}' in context: {p} not found")
|
|
177
|
+
return val
|
|
178
|
+
|
|
179
|
+
def replacer(match):
|
|
180
|
+
expr = match.group(1).strip()
|
|
181
|
+
val = resolve_path(expr, context)
|
|
182
|
+
if isinstance(val, str):
|
|
183
|
+
return repr(val) if add_quotes else str(val)
|
|
184
|
+
elif val is True:
|
|
185
|
+
return "True"
|
|
186
|
+
elif val is False:
|
|
187
|
+
return "False"
|
|
188
|
+
elif val is None:
|
|
189
|
+
return "None"
|
|
190
|
+
else:
|
|
191
|
+
return str(val)
|
|
192
|
+
|
|
193
|
+
return ApiE2ETestsManager.TEMPLATE_PATTERN.sub(replacer, template_str)
|
|
194
|
+
|
|
195
|
+
@staticmethod
|
|
196
|
+
def substitute_stored(expr, scenario_name):
|
|
197
|
+
def repl(match):
|
|
198
|
+
key = match.group(1).strip()
|
|
199
|
+
parts = key.split(".")
|
|
200
|
+
if len(parts) == 1:
|
|
201
|
+
subkey = parts[0]
|
|
202
|
+
return str(ApiE2ETestsManager.global_store.get(f"{scenario_name}.{subkey}", f"<MISSING:{subkey}>"))
|
|
203
|
+
elif len(parts) == 2:
|
|
204
|
+
scen, subkey = parts
|
|
205
|
+
return str(ApiE2ETestsManager.global_store.get(f"{scen}.{subkey}", f"<MISSING:{key}>"))
|
|
206
|
+
else:
|
|
207
|
+
raise ValueError(f"Invalid stored key reference: {key} (Too many dots)")
|
|
208
|
+
|
|
209
|
+
return ApiE2ETestsManager.STORED_PATTERN.sub(repl, expr)
|
|
210
|
+
|
|
211
|
+
@staticmethod
|
|
212
|
+
def store_value(key, value, scenario_name):
|
|
213
|
+
namespaced_key = f"{scenario_name}.{key}"
|
|
214
|
+
ApiE2ETestsManager.global_store[namespaced_key] = value
|
|
215
|
+
print(f" Saved: {namespaced_key} = {value}")
|
|
216
|
+
|
|
217
|
+
@staticmethod
|
|
218
|
+
def try_eval(rendered, expr):
|
|
219
|
+
# Basic supported operators
|
|
220
|
+
operators = [">=", "<=", ">", "<", "==", "!=", "in", "not in", "include"]
|
|
221
|
+
|
|
222
|
+
matched_op = None
|
|
223
|
+
for op in operators:
|
|
224
|
+
if f" {op} " in rendered:
|
|
225
|
+
matched_op = op
|
|
226
|
+
left, right = [part.strip() for part in rendered.split(f" {op} ", 1)]
|
|
227
|
+
break
|
|
228
|
+
|
|
229
|
+
if not matched_op:
|
|
230
|
+
raise AssertionError(f"Unsupported assertion format: '{expr}'")
|
|
231
|
+
|
|
232
|
+
# Try to evaluate left and right sides
|
|
233
|
+
def try_eval_part(value):
|
|
234
|
+
# Try to interpret as int/float/bool/None/string
|
|
235
|
+
if re.match(r"^-?\d+\.\d+$", value):
|
|
236
|
+
return float(value)
|
|
237
|
+
elif re.match(r"^-?\d+$", value):
|
|
238
|
+
return int(value)
|
|
239
|
+
elif value.lower() == "true":
|
|
240
|
+
return True
|
|
241
|
+
elif value.lower() == "false":
|
|
242
|
+
return False
|
|
243
|
+
elif value.lower() == "none":
|
|
244
|
+
return None
|
|
245
|
+
elif value.startswith('"') and value.endswith('"'):
|
|
246
|
+
return value.strip('"')
|
|
247
|
+
elif value.startswith("'") and value.endswith("'"):
|
|
248
|
+
return value.strip("'")
|
|
249
|
+
else:
|
|
250
|
+
return value # treat as literal string
|
|
251
|
+
|
|
252
|
+
left_val = try_eval_part(left)
|
|
253
|
+
right_val = try_eval_part(right)
|
|
254
|
+
|
|
255
|
+
# Comparison logic
|
|
256
|
+
result = False
|
|
257
|
+
if matched_op == "==":
|
|
258
|
+
result = left_val == right_val
|
|
259
|
+
elif matched_op == "!=":
|
|
260
|
+
result = left_val != right_val
|
|
261
|
+
elif matched_op == ">":
|
|
262
|
+
result = left_val > right_val
|
|
263
|
+
elif matched_op == "<":
|
|
264
|
+
result = left_val < right_val
|
|
265
|
+
elif matched_op == ">=":
|
|
266
|
+
result = left_val >= right_val
|
|
267
|
+
elif matched_op == "<=":
|
|
268
|
+
result = left_val <= right_val
|
|
269
|
+
elif matched_op == "in":
|
|
270
|
+
result = left_val in right_val
|
|
271
|
+
elif matched_op == "not in":
|
|
272
|
+
result = left_val not in right_val
|
|
273
|
+
elif matched_op == "include":
|
|
274
|
+
result = right_val in left_val
|
|
275
|
+
|
|
276
|
+
if not result:
|
|
277
|
+
raise AssertionError(
|
|
278
|
+
f"Assertion failed: {expr} → evaluated as {left_val} {matched_op} {right_val}"
|
|
279
|
+
)
|
|
280
|
+
return True
|
|
281
|
+
|
|
282
|
+
@staticmethod
|
|
283
|
+
def run_yaml_test(spec, scenario_name, is_verbose=False):
|
|
284
|
+
base_url = spec.get("base_url", "")
|
|
285
|
+
steps = spec.get("steps", [])
|
|
286
|
+
step_metrics = []
|
|
287
|
+
|
|
288
|
+
print(f"Running test: {spec.get('name', scenario_name)}")
|
|
289
|
+
print("=" * 60)
|
|
290
|
+
# use a session for connection pooling and to set a default timeout
|
|
291
|
+
session = requests.Session()
|
|
292
|
+
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()
|
|
298
|
+
|
|
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}")
|
|
316
|
+
|
|
317
|
+
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:
|
|
341
|
+
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")
|
|
344
|
+
break
|
|
345
|
+
print("=" * 60)
|
|
346
|
+
return step_metrics
|
|
347
|
+
|
|
348
|
+
@staticmethod
|
|
349
|
+
def print_summary(total_time):
|
|
350
|
+
print("\n[SUMMARY] TEST RESULTS")
|
|
351
|
+
print("=" * 80)
|
|
352
|
+
|
|
353
|
+
headers = ["Scenario", "Status", "Time (s)", "Steps"]
|
|
354
|
+
col_widths = [30, 7, 8, 50]
|
|
355
|
+
|
|
356
|
+
header_line = f"{headers[0]:<{col_widths[0]}} | {headers[1]:<{col_widths[1]}} | {headers[2]:<{col_widths[2]}} | {headers[3]}"
|
|
357
|
+
print(header_line)
|
|
358
|
+
print("-" * len(header_line))
|
|
359
|
+
|
|
360
|
+
for result in ApiE2ETestsManager.scenario_results:
|
|
361
|
+
print(f"{result['scenario']:<{col_widths[0]}} | "
|
|
362
|
+
f"{result['status']:<{col_widths[1]}} | "
|
|
363
|
+
f"{result['total_time']:<{col_widths[2]}.2f} | ")
|
|
364
|
+
|
|
365
|
+
if result["steps"]:
|
|
366
|
+
for step in result["steps"]:
|
|
367
|
+
parsed = f"[{step['result']}] {step['name']} ({step['time']:.2f}s)"
|
|
368
|
+
print(f"{'':<{col_widths[0]}} | "
|
|
369
|
+
f"{'':<{col_widths[1]}} | "
|
|
370
|
+
f"{'':<{col_widths[2]}} | "
|
|
371
|
+
f"{parsed:<{col_widths[3]}}")
|
|
372
|
+
if step['result'] == "FAIL" and 'error' in step:
|
|
373
|
+
print(f"{'':<{col_widths[0]}} | "
|
|
374
|
+
f"{'':<{col_widths[1]}} | "
|
|
375
|
+
f"{'':<{col_widths[2]}} | "
|
|
376
|
+
f"===> {step['error']}")
|
|
377
|
+
else:
|
|
378
|
+
print(f"{'':<{col_widths[0]}} | "
|
|
379
|
+
f"{'':<{col_widths[1]}} | "
|
|
380
|
+
f"{'':<{col_widths[2]}} | "
|
|
381
|
+
f"{'(no steps run)':<{col_widths[3]}}")
|
|
382
|
+
|
|
383
|
+
print("=" * 80)
|
|
384
|
+
print(f"Total Execution Time: {total_time:.2f}s\n")
|
|
@@ -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.1",
|
|
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",
|
|
@@ -21,5 +21,6 @@ setup(
|
|
|
21
21
|
python_requires=">=3.7",
|
|
22
22
|
install_requires=[
|
|
23
23
|
"PyYAML>=6.0.1",
|
|
24
|
+
"requests"
|
|
24
25
|
],
|
|
25
26
|
)
|
|
@@ -1,168 +0,0 @@
|
|
|
1
|
-
import yaml
|
|
2
|
-
import requests
|
|
3
|
-
import re
|
|
4
|
-
import os
|
|
5
|
-
import datetime
|
|
6
|
-
|
|
7
|
-
class ApiE2ETestsManager:
|
|
8
|
-
@staticmethod
|
|
9
|
-
def run_all_tests(folder_path, is_verbose=False):
|
|
10
|
-
print("\nRunning API E2E tests...")
|
|
11
|
-
for filename in os.listdir(folder_path):
|
|
12
|
-
if filename.endswith(".yaml") or filename.endswith(".yml"):
|
|
13
|
-
file_path = os.path.join(folder_path, filename)
|
|
14
|
-
print(f"\n--- Running scenario: {filename} ---")
|
|
15
|
-
ApiE2ETestsManager.run_yaml_test(file_path, is_verbose)
|
|
16
|
-
print("\nAPI E2E tests completed.")
|
|
17
|
-
|
|
18
|
-
@staticmethod
|
|
19
|
-
def load_yaml(path):
|
|
20
|
-
with open(path, "r", encoding="utf-8") as f:
|
|
21
|
-
return yaml.safe_load(f)
|
|
22
|
-
|
|
23
|
-
@staticmethod
|
|
24
|
-
def check_type(value, expected_type):
|
|
25
|
-
"""Check if value matches expected type keyword."""
|
|
26
|
-
type_map = {
|
|
27
|
-
"string": str,
|
|
28
|
-
"number": (int, float),
|
|
29
|
-
"boolean": bool,
|
|
30
|
-
"datetime": str # basic check; could extend with parsing
|
|
31
|
-
}
|
|
32
|
-
if expected_type not in type_map:
|
|
33
|
-
raise ValueError(f"Unknown type in YAML: {expected_type}")
|
|
34
|
-
if expected_type == "datetime":
|
|
35
|
-
try:
|
|
36
|
-
datetime.datetime.fromisoformat(value)
|
|
37
|
-
return True
|
|
38
|
-
except Exception:
|
|
39
|
-
try:
|
|
40
|
-
if re.match(r"^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{1,6}$", value):
|
|
41
|
-
datetime.datetime.strptime(value, "%Y-%m-%dT%H:%M:%S.%f")
|
|
42
|
-
return True
|
|
43
|
-
except Exception:
|
|
44
|
-
return False
|
|
45
|
-
return isinstance(value, type_map[expected_type])
|
|
46
|
-
|
|
47
|
-
@staticmethod
|
|
48
|
-
def validate_structure(data, expected):
|
|
49
|
-
"""Recursively validate structure & type expectations."""
|
|
50
|
-
if isinstance(expected, dict):
|
|
51
|
-
if not isinstance(data, dict):
|
|
52
|
-
raise AssertionError(f"Expected dict but got {type(data)}")
|
|
53
|
-
for k, v in expected.items():
|
|
54
|
-
if k not in data:
|
|
55
|
-
raise AssertionError(f"Missing key: {k} (Data keys: {list(data.keys())})")
|
|
56
|
-
ApiE2ETestsManager.validate_structure(data[k], v)
|
|
57
|
-
elif isinstance(expected, list):
|
|
58
|
-
if not isinstance(data, list):
|
|
59
|
-
raise AssertionError(f"Expected list but got {type(data)}")
|
|
60
|
-
if len(expected) > 0:
|
|
61
|
-
for item in data:
|
|
62
|
-
ApiE2ETestsManager.validate_structure(item, expected[0])
|
|
63
|
-
elif isinstance(expected, str): # type keyword
|
|
64
|
-
if not ApiE2ETestsManager.check_type(data, expected):
|
|
65
|
-
raise AssertionError(f"Expected {expected}, got {data} ({type(data)})")
|
|
66
|
-
else:
|
|
67
|
-
raise ValueError(f"Invalid expected type: {expected}")
|
|
68
|
-
|
|
69
|
-
@staticmethod
|
|
70
|
-
def render_template(template_str, context, add_quotes=False):
|
|
71
|
-
def resolve_path(expr, ctx):
|
|
72
|
-
parts = expr.split(".")
|
|
73
|
-
val = ctx
|
|
74
|
-
for p in parts:
|
|
75
|
-
if isinstance(val, dict) and p in val:
|
|
76
|
-
val = val[p]
|
|
77
|
-
else:
|
|
78
|
-
raise KeyError(f"Cannot resolve '{expr}' in context: {p} not found")
|
|
79
|
-
return val
|
|
80
|
-
|
|
81
|
-
def replacer(match):
|
|
82
|
-
expr = match.group(1).strip()
|
|
83
|
-
for root_key in context:
|
|
84
|
-
if expr.startswith(root_key + "."):
|
|
85
|
-
val = resolve_path(expr, context)
|
|
86
|
-
if isinstance(val, str):
|
|
87
|
-
return repr(val) if add_quotes else str(val) # adds quotes safely
|
|
88
|
-
elif val is True:
|
|
89
|
-
return "True"
|
|
90
|
-
elif val is False:
|
|
91
|
-
return "False"
|
|
92
|
-
elif val is None:
|
|
93
|
-
return "None"
|
|
94
|
-
else:
|
|
95
|
-
return str(val)
|
|
96
|
-
raise KeyError(f"Unknown variable: {expr}")
|
|
97
|
-
|
|
98
|
-
return re.sub(r"\{\{\s*([^}]+?)\s*\}\}", replacer, template_str)
|
|
99
|
-
|
|
100
|
-
@staticmethod
|
|
101
|
-
def substitute_stored(endpoint, stored):
|
|
102
|
-
"""Substitute {$stored.key} placeholders."""
|
|
103
|
-
def repl(match):
|
|
104
|
-
key = match.group(1)
|
|
105
|
-
return str(stored.get(key, f"<MISSING:{key}>"))
|
|
106
|
-
return re.sub(r"\{\$stored\.([a-zA-Z0-9_]+)\}", repl, endpoint)
|
|
107
|
-
|
|
108
|
-
@staticmethod
|
|
109
|
-
def run_yaml_test(path, is_verbose=False):
|
|
110
|
-
spec = ApiE2ETestsManager.load_yaml(path)
|
|
111
|
-
base_url = spec.get("base_url", "")
|
|
112
|
-
steps = spec.get("steps", [])
|
|
113
|
-
stored = {}
|
|
114
|
-
|
|
115
|
-
print(f"Running test: {spec['name']}")
|
|
116
|
-
print("=" * 60)
|
|
117
|
-
|
|
118
|
-
for step in steps:
|
|
119
|
-
print(f"Step: {step['name']}")
|
|
120
|
-
url = base_url + ApiE2ETestsManager.substitute_stored(step['endpoint'], stored)
|
|
121
|
-
method = step['method'].upper()
|
|
122
|
-
body = step.get("body")
|
|
123
|
-
expect = step.get("expect", {})
|
|
124
|
-
|
|
125
|
-
# Make HTTP request
|
|
126
|
-
resp = requests.request(method, url, json=body)
|
|
127
|
-
print(f" → {method} {url} -> {resp.status_code}")
|
|
128
|
-
|
|
129
|
-
# Validate status code
|
|
130
|
-
expected_status = expect.get("status")
|
|
131
|
-
if expected_status and resp.status_code != expected_status:
|
|
132
|
-
if is_verbose:
|
|
133
|
-
print(f"Response body: {resp.text}")
|
|
134
|
-
raise AssertionError(f"Expected {expected_status}, got {resp.status_code}")
|
|
135
|
-
|
|
136
|
-
# Parse response JSON
|
|
137
|
-
try:
|
|
138
|
-
resp_json = resp.json()
|
|
139
|
-
except Exception:
|
|
140
|
-
raise AssertionError("Response is not valid JSON")
|
|
141
|
-
|
|
142
|
-
# Validate body structure
|
|
143
|
-
if "body" in expect:
|
|
144
|
-
if is_verbose:
|
|
145
|
-
print(f"Response body: {resp_json}")
|
|
146
|
-
ApiE2ETestsManager.validate_structure(resp_json, expect["body"])
|
|
147
|
-
|
|
148
|
-
# Save variables
|
|
149
|
-
if "save" in step:
|
|
150
|
-
for key, path_expr in step["save"].items():
|
|
151
|
-
rendered = ApiE2ETestsManager.render_template(path_expr, {"body": resp_json, "stored": stored})
|
|
152
|
-
stored[key] = rendered
|
|
153
|
-
print(f" Saved: {key} = {rendered}")
|
|
154
|
-
|
|
155
|
-
# Assertions
|
|
156
|
-
if "assertions" in step:
|
|
157
|
-
for expr in step["assertions"]:
|
|
158
|
-
rendered = ApiE2ETestsManager.render_template(expr, {"body": resp_json, "stored": stored}, add_quotes=True)
|
|
159
|
-
try:
|
|
160
|
-
if not eval(rendered):
|
|
161
|
-
raise AssertionError(f"Assertion failed: {expr}, rendered as '{rendered}'")
|
|
162
|
-
except Exception as e:
|
|
163
|
-
raise AssertionError(f"Assertion error in '{expr}', rendered as '{rendered}': {e}")
|
|
164
|
-
|
|
165
|
-
print(" ✅ Step passed.\n")
|
|
166
|
-
|
|
167
|
-
print("=" * 60)
|
|
168
|
-
print("🎉 All steps passed successfully!")
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{sefrone_api_e2e-1.0.0 → sefrone_api_e2e-1.0.1}/sefrone_api_e2e.egg-info/dependency_links.txt
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|