sefrone-api-e2e 1.0.1__py3-none-any.whl → 1.0.3__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- sefrone_api_e2e/api_e2e_manager.py +205 -56
- {sefrone_api_e2e-1.0.1.dist-info → sefrone_api_e2e-1.0.3.dist-info}/METADATA +1 -1
- sefrone_api_e2e-1.0.3.dist-info/RECORD +6 -0
- sefrone_api_e2e-1.0.1.dist-info/RECORD +0 -6
- {sefrone_api_e2e-1.0.1.dist-info → sefrone_api_e2e-1.0.3.dist-info}/WHEEL +0 -0
- {sefrone_api_e2e-1.0.1.dist-info → sefrone_api_e2e-1.0.3.dist-info}/top_level.txt +0 -0
|
@@ -27,9 +27,11 @@ class ApiE2ETestsManager:
|
|
|
27
27
|
ApiE2ETestsManager.scenario_results = []
|
|
28
28
|
|
|
29
29
|
p = Path(folder_path)
|
|
30
|
-
scenario_files = sorted([f.name for f in p.iterdir() if f.is_file() and f.suffix == ".yaml"])
|
|
30
|
+
scenario_files = sorted([f.name for f in p.iterdir() if f.is_file() and f.suffix == ".yaml"])
|
|
31
31
|
total_start = time.time()
|
|
32
32
|
|
|
33
|
+
all_tests_passed = True
|
|
34
|
+
|
|
33
35
|
for filename in scenario_files:
|
|
34
36
|
yaml_file_path = os.path.join(folder_path, filename)
|
|
35
37
|
scenario_name = os.path.splitext(filename)[0]
|
|
@@ -55,6 +57,7 @@ class ApiE2ETestsManager:
|
|
|
55
57
|
"steps": step_metrics,
|
|
56
58
|
"total_time": elapsed
|
|
57
59
|
})
|
|
60
|
+
all_tests_passed = False
|
|
58
61
|
# mark remaining scenarios as skipped
|
|
59
62
|
remaining = scenario_files[scenario_files.index(filename)+1:]
|
|
60
63
|
for skipped_file in remaining:
|
|
@@ -76,6 +79,7 @@ class ApiE2ETestsManager:
|
|
|
76
79
|
"steps": step_metrics if 'step_metrics' in locals() else [],
|
|
77
80
|
"total_time": failed_elapsed
|
|
78
81
|
})
|
|
82
|
+
all_tests_passed = False
|
|
79
83
|
# mark remaining scenarios as skipped
|
|
80
84
|
remaining = scenario_files[scenario_files.index(filename)+1:]
|
|
81
85
|
for skipped_file in remaining:
|
|
@@ -90,7 +94,10 @@ class ApiE2ETestsManager:
|
|
|
90
94
|
|
|
91
95
|
total_elapsed = time.time() - total_start
|
|
92
96
|
print("\nAPI E2E tests completed.")
|
|
93
|
-
ApiE2ETestsManager.print_summary(total_elapsed)
|
|
97
|
+
ApiE2ETestsManager.print_summary(total_elapsed, all_tests_passed)
|
|
98
|
+
|
|
99
|
+
if not all_tests_passed:
|
|
100
|
+
exit(1)
|
|
94
101
|
|
|
95
102
|
@staticmethod
|
|
96
103
|
def read_env_value_from_file(env_file_path, env_var):
|
|
@@ -167,13 +174,100 @@ class ApiE2ETestsManager:
|
|
|
167
174
|
@staticmethod
|
|
168
175
|
def render_template(template_str, context, add_quotes=False):
|
|
169
176
|
def resolve_path(expr, ctx):
|
|
170
|
-
|
|
177
|
+
# Parse path with array indexing support: body.items[0].id and count(): body.items.count()
|
|
171
178
|
val = ctx
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
179
|
+
current_path = ""
|
|
180
|
+
i = 0
|
|
181
|
+
|
|
182
|
+
while i < len(expr):
|
|
183
|
+
# Check for array index
|
|
184
|
+
if expr[i] == '[':
|
|
185
|
+
# Find closing bracket
|
|
186
|
+
end = expr.find(']', i)
|
|
187
|
+
if end == -1:
|
|
188
|
+
raise ValueError(f"Unclosed bracket in path: {expr}")
|
|
189
|
+
|
|
190
|
+
# Extract index
|
|
191
|
+
index_str = expr[i+1:end].strip()
|
|
192
|
+
try:
|
|
193
|
+
index = int(index_str)
|
|
194
|
+
except ValueError:
|
|
195
|
+
raise ValueError(f"Invalid array index '{index_str}' in path: {expr}")
|
|
196
|
+
|
|
197
|
+
# Apply index
|
|
198
|
+
if not isinstance(val, list):
|
|
199
|
+
raise KeyError(f"Cannot index non-list at '{current_path}' in path: {expr}")
|
|
200
|
+
if index < 0 or index >= len(val):
|
|
201
|
+
raise KeyError(f"Index {index} out of range at '{current_path}' in path: {expr}")
|
|
202
|
+
|
|
203
|
+
val = val[index]
|
|
204
|
+
current_path += f"[{index}]"
|
|
205
|
+
i = end + 1
|
|
206
|
+
|
|
207
|
+
# Skip dot after bracket if present
|
|
208
|
+
if i < len(expr) and expr[i] == '.':
|
|
209
|
+
i += 1
|
|
210
|
+
|
|
211
|
+
# Check for dot separator
|
|
212
|
+
elif expr[i] == '.':
|
|
213
|
+
i += 1
|
|
214
|
+
|
|
215
|
+
# Read property name or function call
|
|
175
216
|
else:
|
|
176
|
-
|
|
217
|
+
# Find next special char
|
|
218
|
+
next_dot = expr.find('.', i)
|
|
219
|
+
next_bracket = expr.find('[', i)
|
|
220
|
+
next_paren = expr.find('(', i)
|
|
221
|
+
|
|
222
|
+
# Determine end of property name
|
|
223
|
+
candidates = [next_dot, next_bracket, next_paren]
|
|
224
|
+
valid_candidates = [c for c in candidates if c != -1]
|
|
225
|
+
|
|
226
|
+
if not valid_candidates:
|
|
227
|
+
end_pos = len(expr)
|
|
228
|
+
else:
|
|
229
|
+
end_pos = min(valid_candidates)
|
|
230
|
+
|
|
231
|
+
prop = expr[i:end_pos].strip()
|
|
232
|
+
if prop:
|
|
233
|
+
# Check if it's a function call
|
|
234
|
+
if end_pos < len(expr) and expr[end_pos] == '(':
|
|
235
|
+
# Handle count() function
|
|
236
|
+
if prop == "count":
|
|
237
|
+
# Find closing parenthesis
|
|
238
|
+
close_paren = expr.find(')', end_pos)
|
|
239
|
+
if close_paren == -1:
|
|
240
|
+
raise ValueError(f"Unclosed parenthesis in function call: {expr}")
|
|
241
|
+
|
|
242
|
+
# Check if there are arguments (should be empty for count)
|
|
243
|
+
args = expr[end_pos+1:close_paren].strip()
|
|
244
|
+
if args:
|
|
245
|
+
raise ValueError(f"count() function does not accept arguments: {expr}")
|
|
246
|
+
|
|
247
|
+
# Apply count on lists or dicts
|
|
248
|
+
if isinstance(val, (list, dict)):
|
|
249
|
+
val = len(val)
|
|
250
|
+
else:
|
|
251
|
+
raise KeyError(f"Cannot call count() on type {type(val).__name__} at '{current_path}' in path: {expr}")
|
|
252
|
+
|
|
253
|
+
current_path += ".count()"
|
|
254
|
+
i = close_paren + 1
|
|
255
|
+
|
|
256
|
+
# Skip dot after parenthesis if present
|
|
257
|
+
if i < len(expr) and expr[i] == '.':
|
|
258
|
+
i += 1
|
|
259
|
+
else:
|
|
260
|
+
raise ValueError(f"Unsupported function '{prop}()' in path: {expr}")
|
|
261
|
+
# Regular property access
|
|
262
|
+
elif isinstance(val, dict) and prop in val:
|
|
263
|
+
val = val[prop]
|
|
264
|
+
current_path += f".{prop}" if current_path and not current_path.endswith(']') else prop
|
|
265
|
+
i = end_pos
|
|
266
|
+
else:
|
|
267
|
+
raise KeyError(f"Cannot resolve '{expr}' in context: property '{prop}' not found at '{current_path}'")
|
|
268
|
+
else:
|
|
269
|
+
i = end_pos
|
|
270
|
+
|
|
177
271
|
return val
|
|
178
272
|
|
|
179
273
|
def replacer(match):
|
|
@@ -208,6 +302,20 @@ class ApiE2ETestsManager:
|
|
|
208
302
|
|
|
209
303
|
return ApiE2ETestsManager.STORED_PATTERN.sub(repl, expr)
|
|
210
304
|
|
|
305
|
+
@staticmethod
|
|
306
|
+
def substitute_stored_in_body(body, scenario_name):
|
|
307
|
+
"""Recursively substitute stored variables in request body."""
|
|
308
|
+
if body is None:
|
|
309
|
+
return None
|
|
310
|
+
elif isinstance(body, dict):
|
|
311
|
+
return {k: ApiE2ETestsManager.substitute_stored_in_body(v, scenario_name) for k, v in body.items()}
|
|
312
|
+
elif isinstance(body, list):
|
|
313
|
+
return [ApiE2ETestsManager.substitute_stored_in_body(item, scenario_name) for item in body]
|
|
314
|
+
elif isinstance(body, str):
|
|
315
|
+
return ApiE2ETestsManager.substitute_stored(body, scenario_name)
|
|
316
|
+
else:
|
|
317
|
+
return body
|
|
318
|
+
|
|
211
319
|
@staticmethod
|
|
212
320
|
def store_value(key, value, scenario_name):
|
|
213
321
|
namespaced_key = f"{scenario_name}.{key}"
|
|
@@ -275,7 +383,7 @@ class ApiE2ETestsManager:
|
|
|
275
383
|
|
|
276
384
|
if not result:
|
|
277
385
|
raise AssertionError(
|
|
278
|
-
f"Assertion failed: {expr}
|
|
386
|
+
f"Assertion failed: {expr} -> evaluated as {left_val} {matched_op} {right_val}"
|
|
279
387
|
)
|
|
280
388
|
return True
|
|
281
389
|
|
|
@@ -286,67 +394,103 @@ class ApiE2ETestsManager:
|
|
|
286
394
|
step_metrics = []
|
|
287
395
|
|
|
288
396
|
print(f"Running test: {spec.get('name', scenario_name)}")
|
|
397
|
+
print(f"Base URL: {base_url}")
|
|
289
398
|
print("=" * 60)
|
|
290
399
|
# use a session for connection pooling and to set a default timeout
|
|
291
400
|
session = requests.Session()
|
|
292
401
|
for step in steps:
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
method = step['method'].upper()
|
|
402
|
+
# Get retry configuration
|
|
403
|
+
retry_config = step.get("retry", {})
|
|
404
|
+
max_attempts = retry_config.get("attempts", 1)
|
|
405
|
+
delay_seconds = retry_config.get("delay_seconds", 0)
|
|
298
406
|
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
for header in step["request_headers"]:
|
|
302
|
-
for header_key, header_value in header.items():
|
|
303
|
-
if isinstance(header_value, str):
|
|
304
|
-
header_value = ApiE2ETestsManager.substitute_stored(header_value, scenario_name)
|
|
305
|
-
request_headers[header_key] = header_value
|
|
306
|
-
|
|
307
|
-
body = step.get("body")
|
|
308
|
-
expect = step.get("expect", {})
|
|
309
|
-
|
|
310
|
-
resp = session.request(method, url, json=body, headers=request_headers, timeout=ApiE2ETestsManager.DEFAULT_TIMEOUT)
|
|
311
|
-
print(f" -> {method} {url} -> {resp.status_code}")
|
|
312
|
-
|
|
313
|
-
expected_status = expect.get("status")
|
|
314
|
-
if expected_status and resp.status_code != expected_status:
|
|
315
|
-
raise AssertionError(f"Expected {expected_status}, got {resp.status_code}")
|
|
407
|
+
last_error = None
|
|
408
|
+
step_start = time.time()
|
|
316
409
|
|
|
410
|
+
for attempt in range(max_attempts):
|
|
317
411
|
try:
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
ApiE2ETestsManager.
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
412
|
+
if attempt > 0:
|
|
413
|
+
print(f" Retry attempt {attempt + 1}/{max_attempts} after {delay_seconds}s delay...")
|
|
414
|
+
time.sleep(delay_seconds)
|
|
415
|
+
|
|
416
|
+
if attempt == 0:
|
|
417
|
+
print(f"Step: {step['name']}")
|
|
418
|
+
|
|
419
|
+
url = base_url + ApiE2ETestsManager.substitute_stored(step['endpoint'], scenario_name)
|
|
420
|
+
method = step['method'].upper()
|
|
421
|
+
|
|
422
|
+
request_headers = {}
|
|
423
|
+
if "request_headers" in step:
|
|
424
|
+
for header in step["request_headers"]:
|
|
425
|
+
for header_key, header_value in header.items():
|
|
426
|
+
if isinstance(header_value, str):
|
|
427
|
+
header_value = ApiE2ETestsManager.substitute_stored(header_value, scenario_name)
|
|
428
|
+
request_headers[header_key] = header_value
|
|
429
|
+
|
|
430
|
+
body = step.get("body")
|
|
431
|
+
if body is not None:
|
|
432
|
+
body = ApiE2ETestsManager.substitute_stored_in_body(body, scenario_name)
|
|
433
|
+
expect = step.get("expect", {})
|
|
434
|
+
|
|
435
|
+
resp = session.request(method, url, json=body, headers=request_headers, timeout=ApiE2ETestsManager.DEFAULT_TIMEOUT)
|
|
436
|
+
print(f" -> {method} {url} -> {resp.status_code}")
|
|
437
|
+
|
|
438
|
+
expected_status = expect.get("status")
|
|
439
|
+
if expected_status and resp.status_code != expected_status:
|
|
440
|
+
raise AssertionError(f"Expected {expected_status}, got {resp.status_code}")
|
|
441
|
+
|
|
442
|
+
try:
|
|
443
|
+
resp_json = resp.json()
|
|
444
|
+
except Exception:
|
|
445
|
+
raise AssertionError("Response is not valid JSON")
|
|
446
|
+
|
|
447
|
+
if "body" in expect:
|
|
448
|
+
if is_verbose:
|
|
449
|
+
print(f"Response body: {resp_json}")
|
|
450
|
+
ApiE2ETestsManager.validate_structure(resp_json, expect["body"])
|
|
451
|
+
|
|
452
|
+
if "assertions" in step:
|
|
453
|
+
for expr in step["assertions"]:
|
|
454
|
+
try:
|
|
455
|
+
rendered = ApiE2ETestsManager.render_template(expr, {"body": resp_json}, add_quotes=True)
|
|
456
|
+
ApiE2ETestsManager.try_eval(rendered, expr)
|
|
457
|
+
except Exception as e:
|
|
458
|
+
raise AssertionError(f"Assertion failed for expression\n -->'{expr}'\n -->{str(e)}")
|
|
459
|
+
|
|
460
|
+
if "save" in step:
|
|
461
|
+
for key, path_expr in step["save"].items():
|
|
462
|
+
try:
|
|
463
|
+
rendered = ApiE2ETestsManager.render_template(path_expr, {"body": resp_json})
|
|
464
|
+
ApiE2ETestsManager.store_value(key, rendered, scenario_name)
|
|
465
|
+
except Exception as e:
|
|
466
|
+
raise AssertionError(f"Failed to render save path\n -->'{path_expr}'\n -->{str(e)}")
|
|
467
|
+
|
|
468
|
+
elapsed = time.time() - step_start
|
|
469
|
+
step_metrics.append({"name": step["name"], "time": elapsed, "result": "PASS"})
|
|
470
|
+
if attempt > 0:
|
|
471
|
+
print(f"[OK ] Step passed on retry attempt {attempt + 1} in {elapsed:.2f}s\n")
|
|
472
|
+
else:
|
|
473
|
+
print(f"[OK ] Step passed in {elapsed:.2f}s\n")
|
|
474
|
+
last_error = None
|
|
475
|
+
break # Success, exit retry loop
|
|
476
|
+
|
|
477
|
+
except Exception as e:
|
|
478
|
+
last_error = e
|
|
479
|
+
if attempt < max_attempts - 1:
|
|
480
|
+
print(f"[RETRY] Attempt {attempt + 1} failed: {str(e)}")
|
|
481
|
+
# Continue to next attempt
|
|
482
|
+
|
|
483
|
+
# Check if all attempts failed
|
|
484
|
+
if last_error is not None:
|
|
341
485
|
failed_elapsed = time.time() - step_start
|
|
342
|
-
step_metrics.append({"name": step["name"], "time": failed_elapsed, "result": "FAIL", "error": str(
|
|
343
|
-
print(f"[FAIL] Step failed in {failed_elapsed:.2f}s
|
|
486
|
+
step_metrics.append({"name": step["name"], "time": failed_elapsed, "result": "FAIL", "error": str(last_error)})
|
|
487
|
+
print(f"[FAIL] Step failed after {max_attempts} attempt(s) in {failed_elapsed:.2f}s\n Error: {str(last_error)}\n")
|
|
344
488
|
break
|
|
345
489
|
print("=" * 60)
|
|
346
490
|
return step_metrics
|
|
347
491
|
|
|
348
492
|
@staticmethod
|
|
349
|
-
def print_summary(total_time):
|
|
493
|
+
def print_summary(total_time, all_tests_passed):
|
|
350
494
|
print("\n[SUMMARY] TEST RESULTS")
|
|
351
495
|
print("=" * 80)
|
|
352
496
|
|
|
@@ -382,3 +526,8 @@ class ApiE2ETestsManager:
|
|
|
382
526
|
|
|
383
527
|
print("=" * 80)
|
|
384
528
|
print(f"Total Execution Time: {total_time:.2f}s\n")
|
|
529
|
+
|
|
530
|
+
if all_tests_passed:
|
|
531
|
+
print("ALL TESTS PASSED")
|
|
532
|
+
else:
|
|
533
|
+
print("SOME TESTS FAILED")
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
sefrone_api_e2e/__init__.py,sha256=NNUHigTCBkjdwCfQXVEA7urxF1-A8HF1_IuWJ8LuO-k,91
|
|
2
|
+
sefrone_api_e2e/api_e2e_manager.py,sha256=vAA0-H3dVKIUptyxXbYaiZP_9PYu58z9LCj_vi53vwY,23584
|
|
3
|
+
sefrone_api_e2e-1.0.3.dist-info/METADATA,sha256=cHEc8npSLW23SM7hxt0GYKY6q8vHCghpPv7oUB-IhUQ,721
|
|
4
|
+
sefrone_api_e2e-1.0.3.dist-info/WHEEL,sha256=tZoeGjtWxWRfdplE7E3d45VPlLNQnvbKiYnx7gwAy8A,92
|
|
5
|
+
sefrone_api_e2e-1.0.3.dist-info/top_level.txt,sha256=19WO3CsUWUiGtZBotT587N-tkxxjctKOPDEHWpHpS8M,16
|
|
6
|
+
sefrone_api_e2e-1.0.3.dist-info/RECORD,,
|
|
@@ -1,6 +0,0 @@
|
|
|
1
|
-
sefrone_api_e2e/__init__.py,sha256=NNUHigTCBkjdwCfQXVEA7urxF1-A8HF1_IuWJ8LuO-k,91
|
|
2
|
-
sefrone_api_e2e/api_e2e_manager.py,sha256=3oA9JqDQR9L_Fj5YRgeXpPcFxl28FRLNN_hdjB1017E,16464
|
|
3
|
-
sefrone_api_e2e-1.0.1.dist-info/METADATA,sha256=-qjd_wb2tI9jnSQKe7rt0fIMe4Is7-012z8SGutmlCw,721
|
|
4
|
-
sefrone_api_e2e-1.0.1.dist-info/WHEEL,sha256=tZoeGjtWxWRfdplE7E3d45VPlLNQnvbKiYnx7gwAy8A,92
|
|
5
|
-
sefrone_api_e2e-1.0.1.dist-info/top_level.txt,sha256=19WO3CsUWUiGtZBotT587N-tkxxjctKOPDEHWpHpS8M,16
|
|
6
|
-
sefrone_api_e2e-1.0.1.dist-info/RECORD,,
|
|
File without changes
|
|
File without changes
|