This commit is contained in:
gongwenxin
2025-05-27 03:18:14 +08:00
parent 1138668a72
commit f0cc525141
33 changed files with 4088 additions and 3619 deletions
@@ -1 +0,0 @@
@@ -1,61 +0,0 @@
"""Assertion Engine Module"""
from typing import Any, Dict, List
# from ..models.rule_models import BusinessAssertionTemplate # Assuming rule_models.py will exist
class AssertionEngine:
"""
Responsible for verifying test step results based on predefined rules.
This is a placeholder and will need significant development based on
how assertion rules are defined and evaluated (e.g., Python expressions, JSONPath, etc.).
"""
def __init__(self):
# Initialization, potentially loading common assertion helpers or context
pass
def evaluate_assertion(self, assertion_rule: Any, context_data: Dict[str, Any]) -> bool:
"""
Evaluates a single assertion rule against the given context data.
Args:
assertion_rule: The rule definition (e.g., a Pydantic model like BusinessAssertionTemplate).
The structure of this will depend on your rule design.
context_data: Data from the test execution context (e.g., API response, extracted variables).
Returns:
True if the assertion passes, False otherwise.
"""
# Placeholder logic - this needs to be implemented based on rule type
# Example: if rule is a python expression
# if assertion_rule.template_language == "python_expression":
# try:
# # Ensure the expression is safe to eval!
# # Consider using ast.literal_eval for simple cases or a safer evaluation library.
# # For complex expressions, a dedicated DSL or restricted environment is better.
# # The context_data would be made available to the expression.
# return bool(eval(assertion_rule.template_expression, {}, context_data))
# except Exception as e:
# print(f"Error evaluating Python expression assertion: {e}")
# return False
# Example: if rule is a simple equality check (defined differently)
# if "expected_value" in assertion_rule and "actual_value_path" in assertion_rule:
# actual_value = get_value_from_path(context_data, assertion_rule.actual_value_path) # Needs helper
# return actual_value == assertion_rule.expected_value
print(f"[AssertionEngine] Placeholder: Evaluating rule '{getattr(assertion_rule, "name", "Unnamed Rule")}'. Context: {context_data}")
# This is a very basic placeholder. Real implementation depends heavily on rule definition.
return True # Default to True for now
# Helper function example (would likely be more complex or use a library like jsonpath-ng)
# def get_value_from_path(data: Dict[str, Any], path: str) -> Any:
# """Retrieves a value from a nested dict using a simple dot-separated path."""
# keys = path.split('.')
# value = data
# for key in keys:
# if isinstance(value, dict) and key in value:
# value = value[key]
# else:
# return None # Or raise an error
# return value
+262 -2
View File
@@ -1,6 +1,7 @@
from enum import Enum
from typing import Any, Dict, Optional, List, Tuple, Type
from typing import Any, Dict, Optional, List, Tuple, Type, Union
import logging
from .utils import schema_utils
class TestSeverity(Enum):
"""测试用例的严重程度"""
@@ -87,6 +88,10 @@ class BaseAPITestCase:
# 新增:测试用例执行顺序 (数值越小越先执行)
execution_order: int = 100
# 新增:标记此测试用例是否为关键的前置设置测试
# 如果此用例失败,后续针对该端点的其他测试用例将被跳过
is_critical_setup_test: bool = False
# LLM 生成控制属性 (默认为 False,表示不使用LLM,除非显式开启)
use_llm_for_body: bool = False
use_llm_for_path_params: bool = False
@@ -238,4 +243,259 @@ class BaseAPITestCase:
# --- Helper to easily create a failed ValidationResult ---
@staticmethod
def failed(message: str, details: Optional[Dict[str, Any]] = None) -> ValidationResult:
return ValidationResult(passed=False, message=message, details=details)
return ValidationResult(passed=False, message=message, details=details)
# --- New helper methods for schema and field finding ---
def _get_resolved_request_body_schema(self) -> Optional[Dict[str, Any]]:
"""
Helper to get the (potentially $ref-resolved by orchestrator) request body schema
from self.endpoint_spec.
The orchestrator is expected to have handled $ref resolution before test case instantiation.
"""
request_body_spec = self.endpoint_spec.get("requestBody")
if request_body_spec and isinstance(request_body_spec, dict):
content = request_body_spec.get("content", {})
# Iterate through common JSON content types or prioritize application/json
# Order matters: more specific first
for ct in ["application/json", "application/merge-patch+json", "application/*+json", "*/*"]:
if ct in content:
media_type_obj = content[ct]
if isinstance(media_type_obj, dict) and isinstance(media_type_obj.get("schema"), dict):
self.logger.debug(f"Found request body schema under content type: {ct}")
return media_type_obj["schema"]
# Fallback for OpenAPI 2.0 (Swagger) style 'in: body' parameter
# This might also be present in OpenAPI 3.0 for compatibility or by mistake
parameters = self.endpoint_spec.get("parameters", [])
if isinstance(parameters, list):
for param in parameters:
if isinstance(param, dict) and param.get("in") == "body":
param_schema = param.get("schema")
if isinstance(param_schema, dict):
self.logger.debug("Found request body schema under 'in: body' parameter (Swagger 2.0 style).")
# Schema for 'in: body' parameter is directly usable
return param_schema
self.logger.debug("No suitable request body schema found in endpoint_spec.")
return None
def _find_removable_field_path(self, schema_to_search: Optional[Dict[str, Any]], schema_name_for_log: str) -> Optional[List[Union[str, int]]]:
"""
Uses schema_utils to find a removable (required) field path within the given schema.
Args:
schema_to_search: The schema dictionary to search within.
schema_name_for_log: A string name for the schema (e.g., "request body", "response header") for logging.
Returns:
A list representing the path to a removable field, or None if not found.
"""
if not schema_to_search:
self.logger.info(f"Schema for '{schema_name_for_log}' is missing or empty. Cannot find removable field.")
return None
removable_path = schema_utils.util_find_removable_field_path_recursive(
current_schema=schema_to_search,
current_path=[],
full_api_spec_for_refs=self.global_api_spec
# schema_utils.resolve_json_schema_references will use discard_refs=True by default
)
if removable_path:
self.logger.info(f"Found a removable field path in '{schema_name_for_log}' schema: '{'.'.join(map(str, removable_path))}'")
else:
self.logger.info(f"No removable (required) field path found in '{schema_name_for_log}' schema.")
return removable_path
def _find_simple_type_field_in_schema(
self,
schema_to_search: Optional[Dict[str, Any]],
schema_name_for_log: str
) -> Optional[Tuple[List[Union[str, int]], str, Dict[str, Any]]]:
"""
(Helper for test cases) Finds the first simple type field (string, integer, number, boolean)
in the given schema using schema_utils.
Args:
schema_to_search: The schema dictionary to search within.
Expected to be already resolved (e.g., from _get_resolved_request_body_schema).
schema_name_for_log: A name for the schema (e.g., "request body", "response body") for logging.
Returns:
A tuple (field_path, field_type, field_schema) if found, otherwise None.
"""
if not schema_to_search:
self.logger.debug(f"_find_simple_type_field_in_schema: Schema for '{schema_name_for_log}' is None or empty, cannot search.")
return None
if not isinstance(schema_to_search, dict):
self.logger.warning(f"_find_simple_type_field_in_schema: Expected schema for '{schema_name_for_log}' to be a dict, got {type(schema_to_search)}.")
return None
self.logger.debug(f"_find_simple_type_field_in_schema: Searching for simple type field in '{schema_name_for_log}' schema...")
found_target = schema_utils.find_first_simple_type_field_recursive(
current_schema=schema_to_search,
logger_param=self.logger # Pass the test case's logger
)
if found_target:
field_path, field_type, field_prop_schema = found_target
self.logger.info(f"_find_simple_type_field_in_schema: Found simple type field in '{schema_name_for_log}': Path={'.'.join(map(str, field_path))}, Type={field_type}")
return field_path, field_type, field_prop_schema
else:
self.logger.debug(f"_find_simple_type_field_in_schema: No simple type field found in '{schema_name_for_log}' schema.")
return None
def _find_first_simple_type_parameter(
self,
param_location: str
) -> Optional[Tuple[List[Union[str, int]], str, Dict[str, Any], str]]:
"""
Finds the first parameter in the specified location (e.g., 'query', 'header')
that is a simple type or contains a simple type if it's an object schema.
Args:
param_location: The location of the parameter ('query', 'header').
Returns:
A tuple (full_path, param_type, param_schema, param_name) if found, otherwise None.
- full_path: Path to the simple type (e.g., ['paramName'] or ['paramName', 'nestedField']).
- param_type: The original type of the simple field.
- param_schema: The schema definition of the simple field.
- param_name: The name of the top-level parameter.
"""
parameters = self.endpoint_spec.get("parameters", [])
if not isinstance(parameters, list):
self.logger.warning(f"_find_first_simple_type_parameter: 'parameters' in endpoint_spec is not a list. Cannot find {param_location} parameter.")
return None
for param_spec in parameters:
if not isinstance(param_spec, dict) or param_spec.get("in") != param_location:
continue
param_name = param_spec.get("name")
if not param_name:
self.logger.warning(f"_find_first_simple_type_parameter: Found a {param_location} parameter without a name. Skipping: {param_spec}")
continue
self.logger.debug(f"_find_first_simple_type_parameter: Checking {param_location} parameter '{param_name}'.")
# Case 1: Parameter schema is directly defined at the top level of param_spec (OpenAPI 3.0)
param_actual_schema = param_spec.get("schema")
if isinstance(param_actual_schema, dict):
schema_type = param_actual_schema.get("type")
if schema_type in ["string", "integer", "number", "boolean"]:
self.logger.info(f"_find_first_simple_type_parameter: Found simple type {param_location} parameter '{param_name}' (type: {schema_type}) via its 'schema'.")
return [param_name], schema_type, param_actual_schema, param_name
elif schema_type == "object":
self.logger.debug(f"_find_first_simple_type_parameter: {param_location} parameter '{param_name}' has an object schema. Searching within...")
# Schema is already resolved by orchestrator, so no need to call resolve_ref here.
found_in_object = self._find_simple_type_field_in_schema(param_actual_schema, f"{param_location} parameter '{param_name}'")
if found_in_object:
nested_path, nested_type, nested_schema = found_in_object
full_path = [param_name] + nested_path
self.logger.info(f"_find_first_simple_type_parameter: Found simple type field within object {param_location} parameter '{param_name}'. Path: {'.'.join(map(str,full_path))}, Type: {nested_type}")
return full_path, nested_type, nested_schema, param_name
# Add other cases if necessary, e.g. array of simple types for query params (though less common for type mismatch target)
# Case 2: Type is defined directly in param_spec (OpenAPI 2.0 / Swagger or simple OpenAPI 3.0 params)
# This is checked after 'schema' as 'schema' is more explicit in OpenAPI 3+
direct_param_type = param_spec.get("type")
if direct_param_type in ["string", "integer", "number", "boolean"]:
# This param_spec itself is the schema for the simple type
self.logger.info(f"_find_first_simple_type_parameter: Found simple type {param_location} parameter '{param_name}' (type: {direct_param_type}) via direct 'type'.")
return [param_name], direct_param_type, param_spec, param_name
self.logger.info(f"_find_first_simple_type_parameter: No suitable simple type field found for {param_location} parameters.")
return None
def _find_required_parameter_name(self, param_in: str) -> Optional[str]:
"""
Finds the name of the first required parameter in the specified location ('query', 'header', 'path').
Args:
param_in: The location of the parameter (e.g., "query", "header", "path").
Returns:
The name of the first required parameter found, or None.
"""
parameters = self.endpoint_spec.get("parameters", [])
if not isinstance(parameters, list):
self.logger.warning(f"'parameters' in endpoint_spec is not a list, cannot find required {param_in} parameter.")
return None
for param_spec in parameters:
if (isinstance(param_spec, dict) and
param_spec.get("in") == param_in and
param_spec.get("required") is True):
param_name = param_spec.get("name")
if param_name:
self.logger.info(f"Found required '{param_in}' parameter: '{param_name}'.")
return param_name
self.logger.info(f"No required '{param_in}' parameter found in endpoint_spec.")
return None
def expect_error_response(
self,
response_context: APIResponseContext,
expected_status_codes: List[int],
expected_error_code_in_body: Optional[Union[str, int]] = None,
error_code_field_name: str = "code",
context_message_prefix: str = "Error response validation"
) -> List[ValidationResult]:
"""
Validates if the response matches expected error conditions.
Args:
response_context: The API response context.
expected_status_codes: A list of expected HTTP status codes (e.g., [400, 422]).
expected_error_code_in_body: Optional. A specific error code expected in the response body.
error_code_field_name: The name of the field in the JSON response body that contains the error code.
context_message_prefix: Prefix for logging and result messages.
Returns:
A list of ValidationResult objects.
"""
results = []
status_code = response_context.status_code
json_content = response_context.json_content
if status_code in expected_status_codes:
msg = f"{context_message_prefix}: Received expected status code {status_code}."
if expected_error_code_in_body is not None:
if isinstance(json_content, dict):
error_code_in_response = json_content.get(error_code_field_name)
if error_code_in_response == expected_error_code_in_body:
results.append(self.passed(f"{msg} Specific error code '{expected_error_code_in_body}' (field: '{error_code_field_name}') found in response body."))
elif error_code_in_response is not None:
results.append(ValidationResult(passed=True, # Still counts as a valid error status, but code mismatch is noted
message=f"{msg} Status code is as expected, but error code in body ('{error_code_field_name}': '{error_code_in_response}') does not match expected '{expected_error_code_in_body}'.",
details={"response_body": json_content}
))
else: # Error code field not found
results.append(ValidationResult(passed=True, # Status is good, but code presence is an issue
message=f"{msg} Status code is as expected, but did not find error code field '{error_code_field_name}' in response body.",
details={"response_body": json_content}
))
else: # JSON content is not a dict
# Add a check for text_content before slicing
raw_text_detail = response_context.text_content[:500] if response_context.text_content else "(No text content)"
results.append(ValidationResult(passed=True, # Status is good, but body isn't inspectable for code
message=f"{msg} Status code is as expected, but response body is not a JSON object, so cannot check for error code '{error_code_field_name}'.",
details={"raw_response_text": raw_text_detail}
))
else: # No specific error code in body to check, status code match is enough
results.append(self.passed(msg))
else:
details = {"received_status_code": status_code, "expected_status_codes": expected_status_codes}
if isinstance(json_content, dict):
details["response_body"] = json_content
else:
# Add a check for text_content before slicing
raw_text_detail = response_context.text_content[:500] if response_context.text_content else "(No text content)"
details["raw_response_text"] = raw_text_detail
results.append(self.failed(
message=f"{context_message_prefix}: Expected status code to be one of {expected_status_codes}, but received {status_code}.",
details=details
))
self.logger.warning(f"{self.id}: {context_message_prefix} failed. Expected status: {expected_status_codes}, Actual: {status_code}")
return results
+96 -34
View File
@@ -26,6 +26,7 @@ from .test_framework_core import ValidationResult, TestSeverity, APIRequestConte
from .test_case_registry import TestCaseRegistry
# 尝试导入 utils.schema_utils
from .utils import schema_utils
from .utils.common_utils import format_url_with_path_params # 新增导入
# 尝试导入 LLMService,如果失败则允许,因为 LLM 功能是可选的
try:
@@ -65,12 +66,17 @@ class ExecutedTestCaseResult:
self.timestamp = datetime.datetime.now()
def to_dict(self) -> Dict[str, Any]:
message=""
if self.message:
message = self.message
else:
message= ";".join([vp.message for vp in self.validation_points])
return {
"test_case_id": self.test_case_id,
"test_case_name": self.test_case_name,
"test_case_severity": self.test_case_severity.value, # 使用枚举值
"status": self.status.value,
"message": self.message,
"message": message,
"duration_seconds": self.duration,
"timestamp": self.timestamp.isoformat(),
"validation_points": [vp.details if vp.details else {"passed": vp.passed, "message": vp.message} for vp in self.validation_points]
@@ -102,6 +108,7 @@ class TestResult: # 原来的 TestResult 被重构为 EndpointExecutionResult
self.start_time = start_time if start_time else datetime.datetime.now()
self.end_time: Optional[datetime.datetime] = None
self.error_message: Optional[str] = None # 如果整个端点测试出错,记录错误信息
self.message: Optional[str] = None
def add_executed_test_case_result(self, result: ExecutedTestCaseResult):
self.executed_test_cases.append(result)
@@ -264,6 +271,7 @@ class TestSummary:
"error_in_execution": self.test_cases_error,
"skipped_during_endpoint_execution": self.test_cases_skipped_in_endpoint,
"success_rate_percentage": f"{self.test_case_success_rate:.2f}",
},
"detailed_results": [result.to_dict() for result in self.detailed_results]
}
@@ -445,20 +453,33 @@ class APITestOrchestrator:
recursion_depth: int = 0
) -> Optional[Type[BaseModel]]:
"""
动态地从JSON Schema字典创建一个Pydantic模型类。
支持嵌套对象和数组。
Args:
schema: JSON Schema字典。
model_name: 要创建的Pydantic模型的名称。
recursion_depth: 当前递归深度,用于防止无限循环。
Returns:
一个Pydantic BaseModel的子类,如果创建失败则返回None。
Dynamically creates a Pydantic model from a JSON schema.
Handles nested schemas, arrays, and various OpenAPI/JSON Schema constructs.
Uses a cache (_dynamic_model_cache) to avoid redefining identical models.
"""
MAX_RECURSION_DEPTH = 10
if recursion_depth > MAX_RECURSION_DEPTH:
self.logger.error(f"创建Pydantic模型 '{model_name}' 时达到最大递归深度 {MAX_RECURSION_DEPTH}。可能存在循环引用。")
# This cache key generation might need refinement for very complex/deep schemas
# For now, using a combination of model_name and sorted schema keys/values
# Important: dicts are unhashable, so we convert to a sorted tuple of items for the cache key.
# This is a simplified cache key; a more robust approach might involve serializing the schema.
# schema_tuple_for_key = tuple(sorted(schema.items())) if isinstance(schema, dict) else schema
# cache_key = (model_name, schema_tuple_for_key, recursion_depth) # Might be too verbose/complex
# Simpler cache key based on model_name only if we assume model_name is sufficiently unique
# for a given schema structure within a run. If schemas can change for the same model_name,
# this needs to be more sophisticated.
# If model_name is unique per structure, this is fine.
# Let's assume model_name is carefully constructed to be unique for each distinct schema structure
# by the calling functions (e.g., _generate_data_from_schema, _build_object_schema_for_params).
# Simplified approach: if a model with this exact name was already created, reuse it.
# This relies on the caller to ensure `model_name` is unique per schema structure.
if model_name in _dynamic_model_cache:
self.logger.debug(f"Reusing cached Pydantic model: {model_name}")
return _dynamic_model_cache[model_name]
if recursion_depth > self.MAX_RECURSION_DEPTH_PYDANTIC:
self.logger.error(f"创建Pydantic模型 '{model_name}' 时达到最大递归深度 {self.MAX_RECURSION_DEPTH_PYDANTIC}。可能存在循环引用。")
return None
# 清理模型名称,使其成为有效的Python标识符
@@ -875,13 +896,15 @@ class APITestOrchestrator:
final_url_template = endpoint_spec_dict.get('path', '')
# 添加日志:打印将要用于替换的路径参数
self.logger.debug(f"Path parameters to be substituted: {current_path_params}")
final_url = self.base_url + final_url_template
for p_name, p_val in current_path_params.items():
placeholder = f"{{{p_name}}}"
if placeholder in final_url_template: # 替换基础路径模板中的占位符
if placeholder in final_url_template: # 检查原始模板中是否存在占位符
final_url = final_url.replace(placeholder, str(p_val))
# 注意: 如果 _prepare_initial_request_data 填充的 final_url 已经包含了 base_url,这里的拼接逻辑需要调整
# 假设 final_url_template 只是 path string e.g. /users/{id}
# 添加日志:打印替换后的URL (在测试用例修改之前)
self.logger.debug(f"URL after path parameter substitution (before TC modify_request_url hook): {final_url}")
# ---- 调用测试用例的 URL 修改钩子 ----
effective_url = final_url # 默认使用原始构建的URL
@@ -1015,9 +1038,9 @@ class APITestOrchestrator:
根据API端点规范,准备初始的请求数据,包括URL(模板)、路径参数、查询参数、头部和请求体。
这些数据将作为测试用例中 generate_* 方法的输入。
"""
method = endpoint_spec.get('method', 'GET').upper()
path_template = endpoint_spec.get('path', '/') # 这是路径模板, e.g., /users/{id}
operation_id = endpoint_spec.get('operationId') or f"{method}_{path_template.replace('/', '_').replace('{', '_').replace('}','')}"
method = endpoint_spec.get("method", "GET").upper()
path_template = endpoint_spec.get("path", "/")
operation_id = endpoint_spec.get("operationId", path_template) # 使用 path 作为 operationId 的 fallback
initial_path_params: Dict[str, Any] = {}
initial_query_params: Dict[str, Any] = {}
@@ -1317,31 +1340,70 @@ class APITestOrchestrator:
endpoint_test_result.finalize_endpoint_test()
return endpoint_test_result
applicable_test_case_classes = self.test_case_registry.get_applicable_test_cases(
applicable_test_case_classes_unordered = self.test_case_registry.get_applicable_test_cases(
endpoint_method=endpoint.method.upper(),
endpoint_path=endpoint.path
)
if not applicable_test_case_classes:
if not applicable_test_case_classes_unordered:
self.logger.info(f"端点 '{endpoint_id}' 没有找到适用的自定义测试用例。")
endpoint_test_result.finalize_endpoint_test()
endpoint_test_result.finalize_endpoint_test() # 确保在返回前调用
return endpoint_test_result
self.logger.info(f"端点 '{endpoint_id}' 发现了 {len(applicable_test_case_classes)} 个适用的测试用例: {[tc.id for tc in applicable_test_case_classes]}")
# 根据 execution_order 排序测试用例
applicable_test_case_classes = sorted(
applicable_test_case_classes_unordered,
key=lambda tc_class: tc_class.execution_order
)
self.logger.info(f"端点 '{endpoint_id}' 发现了 {len(applicable_test_case_classes)} 个适用的测试用例 (已排序): {[tc.id for tc in applicable_test_case_classes]}")
critical_setup_test_failed = False
critical_setup_failure_reason = ""
for tc_class in applicable_test_case_classes:
self.logger.debug(f"准备执行测试用例 '{tc_class.id}' for '{endpoint_id}'")
executed_case_result = self._execute_single_test_case(
test_case_class=tc_class,
endpoint_spec=endpoint,
global_api_spec=global_api_spec
)
endpoint_test_result.add_executed_test_case_result(executed_case_result)
if executed_case_result.status.value == TestResult.Status.FAILED.value:
# 红色
self.logger.debug(f"\033[91m ❌ 测试用例 '{tc_class.id}' 执行失败。\033[0m")
start_single_tc_time = time.monotonic() # 用于计算跳过测试用例的持续时间
if critical_setup_test_failed:
self.logger.warning(f"由于关键的前置测试用例失败,跳过测试用例 '{tc_class.id}' for '{endpoint_id}'. 原因: {critical_setup_failure_reason}")
skipped_tc_duration = time.monotonic() - start_single_tc_time
executed_case_result = ExecutedTestCaseResult(
test_case_id=tc_class.id,
test_case_name=tc_class.name,
test_case_severity=tc_class.severity,
status=ExecutedTestCaseResult.Status.SKIPPED,
validation_points=[],
message=f"由于关键的前置测试失败而被跳过: {critical_setup_failure_reason}",
duration=skipped_tc_duration
)
else:
self.logger.debug(f"准备执行测试用例 '{tc_class.id}' for '{endpoint_id}'")
executed_case_result = self._execute_single_test_case(
test_case_class=tc_class,
endpoint_spec=endpoint,
global_api_spec=global_api_spec
)
# 检查是否是关键测试用例以及是否失败
if hasattr(tc_class, 'is_critical_setup_test') and tc_class.is_critical_setup_test:
if executed_case_result.status in [ExecutedTestCaseResult.Status.FAILED, ExecutedTestCaseResult.Status.ERROR]:
critical_setup_test_failed = True
critical_setup_failure_reason = f"关键测试 '{tc_class.id}' 失败 (状态: {executed_case_result.status.value})。消息: {executed_case_result.message}"
self.logger.error(f"关键的前置测试用例 '{tc_class.id}' for '{endpoint_id}' 失败。后续测试将被跳过。原因: {critical_setup_failure_reason}")
endpoint_test_result.add_executed_test_case_result(executed_case_result)
# 日志部分可以保持不变或根据需要调整
if executed_case_result.status.value == ExecutedTestCaseResult.Status.FAILED.value:
self.logger.debug(f"\033[91m ❌ 测试用例 '{tc_class.id}' 执行失败。\033[0m")
elif executed_case_result.status.value == ExecutedTestCaseResult.Status.PASSED.value :
self.logger.debug(f"\033[92m ✅ 测试用例 '{tc_class.id}' 执行成功。\033[0m")
# 对于SKIPPED和ERROR状态,可以添加不同颜色的日志
elif executed_case_result.status.value == ExecutedTestCaseResult.Status.SKIPPED.value:
self.logger.debug(f"\033[93m ⏭️ 测试用例 '{tc_class.id}' 被跳过。\033[0m") # 黄色
elif executed_case_result.status.value == ExecutedTestCaseResult.Status.ERROR.value:
self.logger.debug(f"\033[91m 💥 测试用例 '{tc_class.id}' 执行时发生错误。\033[0m") # 红色 (与FAILED相同或不同)
self.logger.debug(f"测试用例 '{tc_class.id}' 执行完毕,状态: {executed_case_result.status.value}")
endpoint_test_result.finalize_endpoint_test()
@@ -0,0 +1,51 @@
# -*- coding: utf-8 -*-
import logging
import re
from typing import Dict, Any
logger = logging.getLogger(__name__)
def format_url_with_path_params(path_template: str, path_params: Dict[str, Any]) -> str:
"""
使用提供的路径参数格式化URL路径模板。
例如, path_template='/users/{userId}/items/{itemId}'
path_params={'userId': 123, 'itemId': 'abc'}
-> '/users/123/items/abc'
Args:
path_template: 包含占位符的URL路径,例如 /resource/{id}
path_params: 包含占位符名称及其值的字典。
Returns:
格式化后的URL路径。
"""
url = path_template
try:
# 优先使用 .format(**path_params) 如果所有占位符都能匹配
# 这要求 path_params 中的键与模板中的占位符完全对应
# url = path_template.format(**path_params) # 更简洁,但如果参数不完全匹配会报错
# 使用正则表达式逐个替换更安全,可以处理部分参数或额外参数的情况
for param_name, param_value in path_params.items():
placeholder = f"{{{param_name}}}"
if placeholder in url:
url = url.replace(placeholder, str(param_value))
else:
# Log if a path param was provided but not found in template. Could be optional.
logger.debug(f"Path parameter '{param_name}' provided but placeholder '{placeholder}' not found in template '{path_template}'.")
# 检查是否还有未替换的占位符 (可选,但推荐)
remaining_placeholders = re.findall(r"({[^{}]+?})", url)
if remaining_placeholders:
logger.warning(f"URL '{url}' 中仍有未替换的路径参数占位符: {remaining_placeholders}。原始模板: '{path_template}', 提供参数: {path_params}")
except KeyError as e:
logger.error(f"格式化URL路径 '{path_template}' 失败:路径参数 '{e}' 未在提供的 path_params 中找到。可用参数: {list(path_params.keys())}")
# 根据需要,这里可以选择是返回原始模板还是抛出异常
# return path_template
raise ValueError(f"Missing path parameter {e} for URL template {path_template}") from e
except Exception as e:
logger.error(f"格式化URL路径 '{path_template}' 时发生未知错误: {e}")
raise # 或者返回原始模板
return url
+339 -1
View File
@@ -271,4 +271,342 @@ def util_remove_value_at_path(
return data_container, None, False
logger.error(f"[Util] util_remove_value_at_path 未能在循环内按预期返回。路径: {'.'.join(map(str,path))}")
return data_container, None, False
return data_container, None, False
def util_set_value_at_path(
data_container: Any,
path: List[Union[str, int]],
new_value: Any,
# logger_param: Optional[logging.Logger] = None
) -> Tuple[Any, bool]:
"""
(框架辅助方法) 在嵌套的字典/列表中为指定路径设置新值。
如果路径中的某些部分不存在,会尝试创建它们 (字典会创建,列表会尝试填充到指定索引,但需谨慎)。
返回 (修改后的容器, 是否成功)。
"""
# effective_logger = logger_param or logger
if not path:
logger.error("[Util] util_set_value_at_path: 路径不能为空。")
# 如果路径为空,是否应该用 new_value 替换整个 data_container
# 当前行为:返回原始容器和失败状态,因为路径通常指向容器内部。
# 如果要支持替换整个容器,需要明确此行为。
if data_container is None and new_value is not None: # 特殊情况:如果原始容器是None,且路径为空,则新值成为容器
logger.info("[Util] util_set_value_at_path: 路径为空,原始容器为None,新值将作为新容器返回。")
return new_value, True
elif data_container is not None and new_value is None and not path: # 路径为空,新值为None,则清空容器
logger.info("[Util] util_set_value_at_path: 路径为空,新值为None,容器将被清空 (返回None)。")
return None, True
# 对于路径为空且两者都不是None的情况,目前返回失败,因为通常期望有路径。
# 或者可以考虑直接返回 new_value,意味着整个对象被替换。
# logger.info(f"[Util] util_set_value_at_path: Path is empty. Replacing entire container.")
# return new_value, True # 备选行为:替换整个对象
return data_container, False
# 深拷贝以避免修改原始输入,除非原始输入是None
container_copy = copy.deepcopy(data_container) if data_container is not None else None
current_level = container_copy
try:
for i, key_or_index in enumerate(path):
is_last_element = (i == len(path) - 1)
if is_last_element:
if isinstance(key_or_index, str):
if not isinstance(current_level, dict):
# 如果当前层级不是字典 (例如是None,或是被意外替换为其他类型),无法设置键值对
logger.error(f"[Util] util_set_value_at_path: 路径的最后一部分 '{key_or_index}' (string key) 期望父级是字典,但找到 {type(current_level)}。路径: {'.'.join(map(str,path))}")
# 尝试强制转换为字典?这可能不是预期行为。
# 如果 current_level 是 None 且它是根 (container_copy is None),则初始化 container_copy
if current_level is None and i == 0: # 路径只有一级,且容器本身是None
container_copy = {}
current_level = container_copy
else: # 更深层级的None或类型错误
return data_container, False
current_level[key_or_index] = new_value
logger.info(f"[Util] 在路径 {'.'.join(map(str,path))} (键 '{key_or_index}') 处设置值为 '{new_value}'")
return container_copy, True
elif isinstance(key_or_index, int): # key_or_index is an integer (list index)
if not isinstance(current_level, list):
logger.error(f"[Util] util_set_value_at_path: 路径的最后一部分索引 '{key_or_index}' 期望父级是列表,但找到 {type(current_level)}。路径: {'.'.join(map(str,path))}")
if current_level is None and i == 0: # 路径只有一级,且容器本身是None
container_copy = [None] * (key_or_index + 1) # 创建足够长度的列表
current_level = container_copy
else:
return data_container, False
elif isinstance(key_or_index, int):
# 确保列表足够长以容纳索引
while len(current_level) <= key_or_index:
current_level.append(None) # 用 None 填充直到达到所需长度
current_level[key_or_index] = new_value
logger.info(f"[Util] 在路径 {'.'.join(map(str,path))} (索引 '{key_or_index}') 处设置值为 '{new_value}'")
return container_copy, True
else:
logger.error(f"[Util] util_set_value_at_path: 路径的最后一部分 '{key_or_index}' 类型未知。路径: {'.'.join(map(str,path))}")
return data_container, False
else: # Not the last element, traverse deeper
next_key_or_index_is_int = isinstance(path[i+1], int)
if isinstance(key_or_index, str): # Current path part is a dictionary key
if not isinstance(current_level, dict):
# 如果在根级别且容器是None,则初始化为字典
if current_level is None and i == 0:
container_copy = {}
current_level = container_copy
else:
logger.error(f"[Util] util_set_value_at_path: 路径期望字典,但在 '{key_or_index}' 处找到 {type(current_level)}。路径: {'.'.join(map(str,path[:i+1]))}")
return data_container, False
if key_or_index not in current_level or current_level[key_or_index] is None or \
(next_key_or_index_is_int and not isinstance(current_level[key_or_index], list)) or \
(not next_key_or_index_is_int and not isinstance(current_level[key_or_index], dict)):
# 如果键不存在,或值为None,或类型与下一路径部分不匹配,则创建/重置
logger.debug(f"[Util] util_set_value_at_path: 在路径 '{key_or_index}' 处创建/重置结构。下一个是索引: {next_key_or_index_is_int}")
current_level[key_or_index] = [] if next_key_or_index_is_int else {}
current_level = current_level[key_or_index]
elif isinstance(key_or_index, int): # Current path part is a list index
if not isinstance(current_level, list):
if current_level is None and i == 0:
container_copy = []
current_level = container_copy
else:
logger.error(f"[Util] util_set_value_at_path: 路径期望列表以应用索引 '{key_or_index}',但找到 {type(current_level)}。路径: {'.'.join(map(str,path[:i+1]))}")
return data_container, False
elif isinstance(key_or_index, int):
# 确保列表足够长以容纳索引,并确保该索引处的元素是正确的类型 (list/dict)
while len(current_level) <= key_or_index:
current_level.append(None) # 用 None 填充
if current_level[key_or_index] is None or \
(next_key_or_index_is_int and not isinstance(current_level[key_or_index], list)) or \
(not next_key_or_index_is_int and not isinstance(current_level[key_or_index], dict)):
logger.debug(f"[Util] util_set_value_at_path: 在列表索引 '{key_or_index}' 处创建/重置结构。下一个是索引: {next_key_or_index_is_int}")
current_level[key_or_index] = [] if next_key_or_index_is_int else {}
current_level = current_level[key_or_index]
else:
logger.error(f"[Util] util_set_value_at_path: 路径部分 '{key_or_index}' 类型未知 ({type(key_or_index)})。路径: {'.'.join(map(str,path[:i+1]))}")
return data_container, False
except Exception as e:
logger.error(f"[Util] 在准备设置字段路径 {'.'.join(map(str,path))} 的值时发生错误: {e}", exc_info=True)
return data_container, False
# Should not be reached if logic is correct, path must have at least one element by initial check.
logger.error(f"[Util] util_set_value_at_path 未能在循环内按预期返回。路径: {'.'.join(map(str,path))}")
return data_container, False
def generate_mismatched_value(
original_type: Optional[str],
original_value: Any,
field_schema: Optional[Dict[str, Any]],
logger_param: Optional[logging.Logger] = None
) -> Any:
"""
(框架辅助方法) 根据原始数据类型、原始值和字段 schema 生成一个类型不匹配的值。
主要用于类型不匹配的测试用例。
Args:
original_type: 字段的原始 OpenAPI 类型 (e.g., "string", "integer").
original_value: 字段的原始值 (当前未直接用于生成逻辑,但可供未来扩展).
field_schema: 字段的 schema 定义,用于检查如 "enum" 之类的约束。
logger_param: 可选的 logger 实例。
Returns:
一个与 original_type 不匹配的值。
"""
effective_logger = logger_param or logger
# 优先考虑 schema 中的 enum,选择一个不在 enum 中且类型不匹配的值
if field_schema and "enum" in field_schema and isinstance(field_schema["enum"], list):
enum_values = field_schema["enum"]
if original_type == "string":
if 123 not in enum_values: return 123
if False not in enum_values: return False
# 如果数字和布尔都在枚举中,尝试一个与已知枚举值不同的字符串
# (虽然这仍然是字符串类型,但目的是为了触发非枚举值的验证)
# 或者,如果目的是严格类型不匹配,这里应该返回非字符串。
# 当前逻辑倾向于返回一个肯定非字符串的值。
elif original_type == "integer":
if "not-an-integer" not in enum_values: return "not-an-integer"
if 3.14 not in enum_values: return 3.14
elif original_type == "number": # Includes float/double
if "not-a-number" not in enum_values: return "not-a-number"
elif original_type == "boolean":
if "not-a-boolean" not in enum_values: return "not-a-boolean"
if 1 not in enum_values: return 1
# 如果枚举覆盖了所有简单备选,则回退到下面的通用逻辑
# 通用类型不匹配逻辑 (当 enum 不存在或 enum 检查未返回时)
if original_type == "string":
return 12345 # Number instead of string
elif original_type == "integer":
return "not-an-integer" # String instead of integer
elif original_type == "number": # Includes float/double
return "not-a-number" # String instead of number
elif original_type == "boolean":
return "not-a-boolean" # String instead of boolean
elif original_type == "array":
return {"value": "not-an-array"} # Object instead of array
elif original_type == "object":
return ["not", "an", "object"] # Array instead of object
effective_logger.warning(f"generate_mismatched_value: 原始类型 '{original_type}' 未知或无法生成不匹配值。将返回固定字符串 'mismatch_test_default'")
return "mismatch_test_default" # Fallback for unknown types
def build_object_schema_for_params(params_spec_list: List[Dict[str, Any]], model_name_base: str, logger_param: Optional[logging.Logger] = None) -> Tuple[Optional[Dict[str, Any]], str]:
"""
从参数规范列表构建一个对象的JSON schema,主要用于请求体、查询参数或头部的聚合。
Args:
params_spec_list: 参数规范的列表 (例如,OpenAPI参数对象列表)。
model_name_base: 用于生成动态模型名称的基础字符串。
logger_param: 可选的 logger 实例。
Returns:
一个元组,包含 (构建的JSON object schema 或 None, 模型名称字符串)。
"""
effective_logger = logger_param or logger # Use passed logger or module logger
if not params_spec_list:
effective_logger.debug(f"参数列表为空,无需为 '{model_name_base}' 构建对象 schema。")
return None, f"{model_name_base}EmptyParams"
properties = {}
required_fields = []
for param_spec in params_spec_list:
param_name = param_spec.get("name")
if not param_name:
effective_logger.warning(f"参数规范缺少 'name' 字段,已跳过: {param_spec}")
continue
# 从参数规范中提取 schema (OpenAPI 3.x)
param_schema = param_spec.get("schema")
if not param_schema:
# 尝试兼容 OpenAPI 2.0 (Swagger) 的情况,其中类型信息直接在参数级别
# 例如: type, format, items, default, enum 等
# https://swagger.io/specification/v2/#parameterObject
# 注意: 这种兼容性可能不完整,因为很多属性需要映射
compatible_schema = {}
if "type" in param_spec:
compatible_schema["type"] = param_spec["type"]
if "format" in param_spec:
compatible_schema["format"] = param_spec["format"]
if "items" in param_spec: # for array types
compatible_schema["items"] = param_spec["items"]
if "default" in param_spec:
compatible_schema["default"] = param_spec["default"]
if "enum" in param_spec:
compatible_schema["enum"] = param_spec["enum"]
# 其他如 description, example 等也可以考虑加入
if compatible_schema: # 如果至少收集到了一些类型信息
param_schema = compatible_schema
effective_logger.debug(f"参数 '{param_name}' 没有 'schema' 字段,但从顶级字段构建了兼容 schema: {param_schema}")
else:
effective_logger.warning(f"参数 '{param_name}' 缺少 'schema' 字段且无法构建兼容schema,已跳过。规范: {param_spec}")
continue
properties[param_name] = param_schema
if param_spec.get("required", False):
required_fields.append(param_name)
if not properties:
effective_logger.debug(f"未能从参数列表为 '{model_name_base}' 提取任何属性。")
return None, f"{model_name_base}NoProps"
final_schema: Dict[str, Any] = {
"type": "object",
"properties": properties
}
if required_fields:
final_schema["required"] = required_fields
# 生成一个稍微独特的名字,以防多个操作有相同的 param_type
# 例如 OperationIdQueryRequest, OperationIdHeaderRequest
model_name = f"{model_name_base.replace(' ', '')}Params"
effective_logger.debug(f"'{model_name_base}' 构建的对象 schema: {final_schema}, 模型名: {model_name}")
return final_schema, model_name
def find_first_simple_type_field_recursive(
current_schema: Dict[str, Any],
current_path: Optional[List[Union[str, int]]] = None,
# full_api_spec_for_refs: Optional[Dict[str, Any]] = None, # Schema is expected to be pre-resolved
logger_param: Optional[logging.Logger] = None
) -> Optional[Tuple[List[Union[str, int]], str, Dict[str, Any]]]:
"""
递归地在给定的 schema 中查找第一个简单类型的字段 (string, integer, number, boolean)。
这包括查找嵌套在对象或数组中的简单类型字段。
Args:
current_schema: 当前正在搜索的 schema 部分 (应为字典)。
current_path: 到达当前 schema 的路径列表 (用于构建完整路径)。
logger_param: 可选的 logger 实例。
Returns:
一个元组 (field_path, field_type, field_schema) 如果找到,否则为 None。
field_path 是一个列表,表示从根 schema 到找到的字段的路径。
field_type 是字段的原始类型字符串 (e.g., "string")。
field_schema 是该字段自身的 schema 定义。
"""
effective_logger = logger_param or logger # Use module logger if specific one not provided
path_so_far = current_path if current_path is not None else []
if not isinstance(current_schema, dict):
effective_logger.debug(f"Schema at path {'.'.join(map(str, path_so_far))} is not a dict, cannot search further.")
return None
schema_type = current_schema.get("type")
# effective_logger.debug(f"Searching in path: {'.'.join(map(str, path_so_far))}, Schema Type: '{schema_type}'")
if schema_type == "object":
properties = current_schema.get("properties", {})
for name, prop_schema in properties.items():
if not isinstance(prop_schema, dict):
effective_logger.debug(f"Property '{name}' at path {'.'.join(map(str, path_so_far + [name]))} has non-dict schema. Skipping.")
continue
prop_type = prop_schema.get("type")
if prop_type in ["string", "integer", "number", "boolean"]:
field_path = path_so_far + [name]
effective_logger.info(f"Found simple type field: Path={'.'.join(map(str, field_path))}, Type={prop_type}")
return field_path, prop_type, prop_schema
elif prop_type == "object":
found_in_nested_object = find_first_simple_type_field_recursive(
prop_schema,
path_so_far + [name],
logger_param=effective_logger
)
if found_in_nested_object:
return found_in_nested_object
elif prop_type == "array":
items_schema = prop_schema.get("items")
if isinstance(items_schema, dict):
# Look for simple type or object within array items
item_type = items_schema.get("type")
if item_type in ["string", "integer", "number", "boolean"]:
field_path = path_so_far + [name, 0] # Target first item of the array
effective_logger.info(f"Found simple type field in array item: Path={'.'.join(map(str, field_path))}, Type={item_type}")
return field_path, item_type, items_schema
elif item_type == "object":
# Path to the first item of the array, then recurse into that item's object schema
found_in_array_item_object = find_first_simple_type_field_recursive(
items_schema,
path_so_far + [name, 0],
logger_param=effective_logger
)
if found_in_array_item_object:
return found_in_array_item_object
elif schema_type == "array": # If the current_schema itself is an array (e.g., root schema is an array)
items_schema = current_schema.get("items")
if isinstance(items_schema, dict):
item_type = items_schema.get("type")
if item_type in ["string", "integer", "number", "boolean"]:
field_path = path_so_far + [0] # Target first item of this root/current array
effective_logger.info(f"Found simple type field in root/current array item: Path={'.'.join(map(str, field_path))}, Type={item_type}")
return field_path, item_type, items_schema
elif item_type == "object":
# Path to the first item of this root/current array, then recurse
found_in_root_array_item_object = find_first_simple_type_field_recursive(
items_schema,
path_so_far + [0],
logger_param=effective_logger
)
if found_in_root_array_item_object:
return found_in_root_array_item_object
# effective_logger.debug(f"No simple type field found at path {'.'.join(map(str, path_so_far))}")
return None