fix:crud流程测试修复
This commit is contained in:
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -385,7 +385,8 @@ class DMSEndpoint(BaseEndpoint):
|
||||
category_name: Optional[str] = None,
|
||||
raw_record: Optional[Dict[str, Any]] = None,
|
||||
test_mode: str = 'standalone',
|
||||
operation_id: Optional[str] = None):
|
||||
operation_id: Optional[str] = None,
|
||||
model_pk_name: Optional[str] = None):
|
||||
super().__init__(method=method.upper(), path=path)
|
||||
self.title = title
|
||||
self.request_body = request_body
|
||||
@@ -395,6 +396,7 @@ class DMSEndpoint(BaseEndpoint):
|
||||
self._raw_record = raw_record
|
||||
self.test_mode = test_mode
|
||||
self.operation_id = operation_id or f"{self.method.lower()}_{self.category_name or 'dms'}_{title.replace(' ', '_')}"
|
||||
self.model_pk_name = model_pk_name
|
||||
|
||||
def to_dict(self) -> Dict[str, Any]:
|
||||
"""Converts the DMS endpoint data into a standardized OpenAPI-like dictionary."""
|
||||
@@ -411,7 +413,8 @@ class DMSEndpoint(BaseEndpoint):
|
||||
"responses": self.responses,
|
||||
"_source_format": "dms",
|
||||
"_dms_raw_record": self._raw_record,
|
||||
"_test_mode": self.test_mode
|
||||
"_test_mode": self.test_mode,
|
||||
"_dms_model_pk_name": self.model_pk_name
|
||||
}
|
||||
return endpoint_dict
|
||||
|
||||
@@ -599,12 +602,25 @@ class InputParser:
|
||||
continue
|
||||
|
||||
model_data = model_schema_response['data']
|
||||
model = model_data.get('model')
|
||||
# Based on user feedback, model_data itself is the schema, not model_data['model']
|
||||
model = model_data
|
||||
if not model or 'properties' not in model or not model['properties']:
|
||||
self.logger.warning(f"Skipping API '{name}' due to missing or invalid 'model' object in schema.")
|
||||
continue
|
||||
|
||||
pk_name = next(iter(model['properties']), None)
|
||||
pk_name = None
|
||||
# Find primary key by looking for top-level "identityId" array.
|
||||
identity_id_list = model.get("identityId")
|
||||
if isinstance(identity_id_list, list) and len(identity_id_list) > 0:
|
||||
pk_name = identity_id_list[0]
|
||||
self.logger.info(f"Found identityId property '{pk_name}' for model '{name}'.")
|
||||
|
||||
# Fallback to original behavior if no identityId found
|
||||
if not pk_name:
|
||||
pk_name = next(iter(model['properties']), None)
|
||||
if pk_name:
|
||||
self.logger.warning(f"No 'identityId' array found for model '{name}'. Falling back to using the first property '{pk_name}' as the primary key.")
|
||||
|
||||
if not pk_name:
|
||||
self.logger.warning(f"Skipping API '{name}' because no properties found in model to identify a primary key.")
|
||||
continue
|
||||
@@ -620,27 +636,27 @@ class InputParser:
|
||||
# Create Endpoint (POST)
|
||||
create_path = f"/api/dms/{dms_instance_code}/v1/{name}"
|
||||
create_request_body_schema = {"type": "object", "properties": {"version": {"type": "string", "example": version}, "act": {"type": "integer", "example": 0}, "data": {"type": "array", "items": model}}, "required": ["data"]}
|
||||
endpoints.append(DMSEndpoint(path=create_path, method='post', title=f"Create {name}", request_body={'content': {'application/json': {'schema': create_request_body_schema}}}, responses=success_response, test_mode='standalone', operation_id=f"create_{name}", category_name=category_name, raw_record=item))
|
||||
endpoints.append(DMSEndpoint(path=create_path, method='post', title=f"Create {name}", request_body={'content': {'application/json': {'schema': create_request_body_schema}}}, responses=success_response, test_mode='scenario_only', operation_id=f"create_{name}", category_name=category_name, raw_record=item, model_pk_name=pk_name))
|
||||
|
||||
# List Endpoint (POST)
|
||||
list_path = f"/api/dms/{dms_instance_code}/v1/{name}/{version}"
|
||||
list_response_schema = {"type": "object", "properties": {"code": {"type": "integer"}, "message": {"type": "string"}, "data": {"type": "array", "items": model}}}
|
||||
endpoints.append(DMSEndpoint(path=list_path, method='post', title=f"List {name}", request_body={'content': {'application/json': {'schema': {}}}}, responses={'200': {'description': 'Successful Operation', 'content': {'application/json': {'schema': list_response_schema}}}}, test_mode='scenario_only', operation_id=f"list_{name}", category_name=category_name, raw_record=item))
|
||||
endpoints.append(DMSEndpoint(path=list_path, method='post', title=f"List {name}", request_body={'content': {'application/json': {'schema': {}}}}, responses={'200': {'description': 'Successful Operation', 'content': {'application/json': {'schema': list_response_schema}}}}, test_mode='standalone', operation_id=f"list_{name}", category_name=category_name, raw_record=item, model_pk_name=pk_name))
|
||||
|
||||
# Read Endpoint (GET)
|
||||
read_path = f"/api/dms/{dms_instance_code}/v1/{name}/{version}/{{id}}"
|
||||
read_response_schema = {"type": "object", "properties": {"code": {"type": "integer"}, "message": {"type": "string"}, "data": model}}
|
||||
read_parameters = [{'name': 'id', 'in': 'path', 'required': True, 'description': f'The ID of the {name}, maps to {pk_name}', 'schema': pk_schema}]
|
||||
endpoints.append(DMSEndpoint(path=read_path, method='get', title=f"Read {name}", request_body=None, responses={'200': {'description': 'Successful Operation', 'content': {'application/json': {'schema': read_response_schema}}}}, parameters=read_parameters, test_mode='scenario_only', operation_id=f"read_{name}", category_name=category_name, raw_record=item))
|
||||
endpoints.append(DMSEndpoint(path=read_path, method='get', title=f"Read {name}", request_body=None, responses={'200': {'description': 'Successful Operation', 'content': {'application/json': {'schema': read_response_schema}}}}, parameters=read_parameters, test_mode='scenario_only', operation_id=f"read_{name}", category_name=category_name, raw_record=item, model_pk_name=pk_name))
|
||||
|
||||
# Update Endpoint (PUT)
|
||||
update_path = f"/api/dms/{dms_instance_code}/v1/{name}"
|
||||
endpoints.append(DMSEndpoint(path=update_path, method='put', title=f"Update {name}", request_body={'content': {'application/json': {'schema': create_request_body_schema}}}, responses=success_response, test_mode='scenario_only', operation_id=f"update_{name}", category_name=category_name, raw_record=item))
|
||||
endpoints.append(DMSEndpoint(path=update_path, method='put', title=f"Update {name}", request_body={'content': {'application/json': {'schema': create_request_body_schema}}}, responses=success_response, test_mode='scenario_only', operation_id=f"update_{name}", category_name=category_name, raw_record=item, model_pk_name=pk_name))
|
||||
|
||||
# Delete Endpoint (DELETE)
|
||||
delete_path = f"/api/dms/{dms_instance_code}/v1/{name}"
|
||||
delete_request_body_schema = {"type": "object", "properties": {"version": {"type": "string", "example": version}, "data": {"type": "array", "items": {"type": "object", "properties": { pk_name: pk_schema }, "required": [pk_name]}}}, "required": ["data"]}
|
||||
endpoints.append(DMSEndpoint(path=delete_path, method='delete', title=f"Delete {name}", request_body={'content': {'application/json': {'schema': delete_request_body_schema}}}, responses=success_response, test_mode='scenario_only', operation_id=f"delete_{name}", category_name=category_name, raw_record=item))
|
||||
endpoints.append(DMSEndpoint(path=delete_path, method='delete', title=f"Delete {name}", request_body={'content': {'application/json': {'schema': delete_request_body_schema}}}, responses=success_response, test_mode='scenario_only', operation_id=f"delete_{name}", category_name=category_name, raw_record=item, model_pk_name=pk_name))
|
||||
|
||||
# The 'spec' for ParsedDMSSpec should represent the whole document.
|
||||
# We can construct a dictionary holding all the raw data we fetched.
|
||||
|
||||
@@ -7,6 +7,7 @@ from typing import List, Dict, Any, Callable, Optional, Union
|
||||
from enum import Enum
|
||||
from datetime import datetime
|
||||
from dataclasses import dataclass, field
|
||||
import copy # Added for deepcopy
|
||||
|
||||
# Add Pydantic BaseModel for APIOperationSpec
|
||||
from pydantic import BaseModel
|
||||
@@ -14,7 +15,8 @@ from pydantic import BaseModel
|
||||
from .test_framework_core import ValidationResult, APIResponseContext
|
||||
from .api_caller.caller import APICallDetail
|
||||
# Import ParsedAPISpec and endpoint types for type hinting and usage
|
||||
from .input_parser.parser import ParsedAPISpec, BaseEndpoint, YAPIEndpoint, SwaggerEndpoint, DMSEndpoint
|
||||
from .input_parser.parser import ParsedAPISpec, YAPIEndpoint, SwaggerEndpoint,BaseEndpoint,DMSEndpoint
|
||||
from .utils.context_utils import serialize_context_recursively
|
||||
|
||||
# 尝试从 .llm_utils 导入,如果失败则 LLMService 为 None
|
||||
try:
|
||||
@@ -89,6 +91,18 @@ class ExecutedStageStepResult:
|
||||
self.context_after_step = context_after_step if context_after_step is not None else {}
|
||||
self.timestamp: datetime = datetime.now()
|
||||
|
||||
def finalize_step_result(self):
|
||||
"""如果步骤的主消息为空,则根据验证点结果更新它。"""
|
||||
if not self.message and hasattr(self, 'validation_points') and self.validation_points:
|
||||
# Note: self.validation_points is a list of DICTS here, not objects.
|
||||
# We need to access dict keys.
|
||||
failed_vp_messages = [
|
||||
vp.get("message") for vp in self.validation_points
|
||||
if isinstance(vp, dict) and not vp.get("passed") and vp.get("message")
|
||||
]
|
||||
if failed_vp_messages:
|
||||
self.message = "; ".join(failed_vp_messages)
|
||||
|
||||
def to_dict(self) -> Dict[str, Any]:
|
||||
return {
|
||||
"step_name": self.step_name,
|
||||
@@ -99,10 +113,10 @@ class ExecutedStageStepResult:
|
||||
"resolved_endpoint": self.resolved_endpoint,
|
||||
"duration_seconds": f"{self.duration_seconds:.4f}",
|
||||
"timestamp": self.timestamp.isoformat(),
|
||||
"request_details": self.request_details,
|
||||
"request_details": serialize_context_recursively(self.request_details),
|
||||
"api_call_details": self.api_call_details,
|
||||
"validation_points": self.validation_points,
|
||||
"context_snapshot_after_step": self.context_after_step # 可以选择是否完整记录上下文
|
||||
"context_snapshot_after_step": serialize_context_recursively(self.context_after_step) # 可以选择是否完整记录上下文
|
||||
}
|
||||
|
||||
|
||||
@@ -119,15 +133,17 @@ class ExecutedStageResult:
|
||||
stage_id: str,
|
||||
stage_name: str,
|
||||
description: Optional[str] = None,
|
||||
api_group_metadata: Optional[Dict[str, Any]] = None, # 存储阶段应用到的API分组元数据
|
||||
tags: Optional[List[str]] = None, # <-- Added tags parameter
|
||||
api_group_metadata: Optional[Dict[str, Any]] = None,
|
||||
apis_in_group: Optional[List[BaseEndpoint]] = None,
|
||||
tags: Optional[List[str]] = None,
|
||||
overall_status: Status = Status.PENDING,
|
||||
message: str = ""):
|
||||
self.stage_id = stage_id
|
||||
self.stage_name = stage_name
|
||||
self.description = description
|
||||
self.api_group_metadata = api_group_metadata
|
||||
self.tags = tags if tags is not None else [] # <-- Store tags
|
||||
self.apis_in_group = apis_in_group if apis_in_group is not None else []
|
||||
self.tags = tags if tags is not None else []
|
||||
self.overall_status = overall_status
|
||||
self.message = message
|
||||
self.executed_steps: List[ExecutedStageStepResult] = []
|
||||
@@ -139,24 +155,31 @@ class ExecutedStageResult:
|
||||
def add_step_result(self, step_result: ExecutedStageStepResult):
|
||||
self.executed_steps.append(step_result)
|
||||
|
||||
def finalize_stage_result(self, final_context: Optional[Dict[str, Any]] = None): # Renamed from finalize_result
|
||||
def finalize_stage_result(self, final_context: Optional[Dict[str, Any]] = None):
|
||||
self.end_time = datetime.now()
|
||||
self.duration = (self.end_time - self.start_time).total_seconds()
|
||||
self.final_context_snapshot = final_context if final_context is not None else {}
|
||||
self.final_context_snapshot = serialize_context_recursively(final_context) if final_context is not None else {}
|
||||
|
||||
# 确定最终状态的逻辑可以放在这里,或者由编排器在调用后设置
|
||||
if not self.executed_steps and self.overall_status == ExecutedStageResult.Status.PENDING:
|
||||
self.overall_status = ExecutedStageResult.Status.SKIPPED # 如果没有步骤执行,且状态未被其他方式设置
|
||||
self.overall_status = ExecutedStageResult.Status.SKIPPED
|
||||
self.message = self.message or "阶段中没有步骤被执行或所有步骤被跳过。"
|
||||
# 更复杂的最终状态判定可能需要编排器逻辑
|
||||
|
||||
def to_dict(self) -> Dict[str, Any]:
|
||||
serializable_apis = []
|
||||
if self.apis_in_group:
|
||||
for endpoint in self.apis_in_group:
|
||||
if hasattr(endpoint, 'to_dict') and callable(endpoint.to_dict):
|
||||
serializable_apis.append(endpoint.to_dict())
|
||||
else:
|
||||
serializable_apis.append(str(endpoint))
|
||||
|
||||
return {
|
||||
"stage_id": self.stage_id,
|
||||
"stage_name": self.stage_name,
|
||||
"description": self.description,
|
||||
"api_group_metadata": self.api_group_metadata,
|
||||
"tags": self.tags, # <-- Added tags to output
|
||||
"api_group_metadata": serialize_context_recursively(self.api_group_metadata),
|
||||
"apis_in_group": serializable_apis,
|
||||
"tags": self.tags,
|
||||
"overall_status": self.overall_status.value,
|
||||
"message": self.message,
|
||||
"duration_seconds": f"{self.duration:.2f}",
|
||||
@@ -199,7 +222,7 @@ class BaseAPIStage:
|
||||
|
||||
def __init__(self,
|
||||
api_group_metadata: Dict[str, Any],
|
||||
apis_in_group: List[BaseEndpoint], # MODIFIED TYPE HINT to use BaseEndpoint
|
||||
apis_in_group: List[Union[YAPIEndpoint, SwaggerEndpoint]], # MODIFIED TYPE HINT
|
||||
llm_service: Optional[LLMService] = None,
|
||||
global_api_spec: Optional[ParsedAPISpec] = None, # <--- 修改类型注解
|
||||
operation_keywords: Optional[Dict[str, List[str]]] = None):
|
||||
@@ -523,205 +546,3 @@ class BaseAPIStage:
|
||||
def after_step(self, step: StageStepDefinition, step_result: 'ExecutedStageStepResult', stage_context: Dict[str, Any], global_api_spec: Optional[ParsedAPISpec] = None, api_group_name: Optional[str] = None):
|
||||
"""在每个步骤执行之后调用。"""
|
||||
self.logger.debug(f"Executing after_step for step '{step.name}' in stage '{self.id}'")
|
||||
|
||||
|
||||
class ExecutedStageStepResult:
|
||||
"""存储单个API测试阶段步骤执行后的结果。"""
|
||||
class Status(str, Enum):
|
||||
PASSED = "通过"
|
||||
FAILED = "失败"
|
||||
ERROR = "执行错误"
|
||||
SKIPPED = "跳过"
|
||||
PENDING = "处理中"
|
||||
|
||||
def __init__(self,
|
||||
step_name: str,
|
||||
status: Status,
|
||||
message: str = "",
|
||||
validation_points: Optional[List[ValidationResult]] = None,
|
||||
duration: float = 0.0,
|
||||
api_call_detail: Optional[APICallDetail] = None,
|
||||
api_operation_spec: Optional[BaseEndpoint] = None, # <--- 添加此行
|
||||
extracted_outputs: Optional[Dict[str, Any]] = None,
|
||||
description: Optional[str] = None,
|
||||
lookup_key: Optional[Union[str, Dict[str, str]]] = None,
|
||||
resolved_endpoint: Optional[str] = None,
|
||||
request_details: Optional[Dict[str, Any]] = None,
|
||||
context_after_step: Optional[Dict[str, Any]] = None
|
||||
):
|
||||
self.step_name = step_name
|
||||
self.status = status
|
||||
self.message = message
|
||||
self.validation_points = validation_points or []
|
||||
self.duration = duration
|
||||
self.timestamp = time.time()
|
||||
self.api_call_detail = api_call_detail
|
||||
self.api_operation_spec = api_operation_spec # <--- 添加此行
|
||||
self.extracted_outputs = extracted_outputs or {}
|
||||
self.description = description
|
||||
self.lookup_key = lookup_key
|
||||
self.resolved_endpoint = resolved_endpoint
|
||||
self.request_details = request_details
|
||||
self.context_after_step = context_after_step
|
||||
|
||||
def finalize_step_result(self):
|
||||
"""如果步骤的主消息为空,则根据验证点结果更新它。"""
|
||||
if not self.message and hasattr(self, 'validation_points') and self.validation_points:
|
||||
failed_vp_messages = [
|
||||
vp.message for vp in self.validation_points
|
||||
if isinstance(vp, ValidationResult) and not vp.passed and vp.message
|
||||
]
|
||||
if failed_vp_messages:
|
||||
self.message = "; ".join(failed_vp_messages)
|
||||
|
||||
def to_dict(self) -> Dict[str, Any]:
|
||||
vps_details_for_output = []
|
||||
if self.validation_points: # self.validation_points is List[ValidationResult]
|
||||
for vp_obj in self.validation_points: # vp_obj is a ValidationResult object
|
||||
if not isinstance(vp_obj, ValidationResult): # Defensive check
|
||||
logger.warning(f"Step '{self.step_name}': Found non-ValidationResult item in validation_points: {type(vp_obj)}")
|
||||
continue
|
||||
|
||||
processed_detail = {"passed": vp_obj.passed, "message": vp_obj.message}
|
||||
details_content = getattr(vp_obj, 'details', None)
|
||||
|
||||
if details_content and isinstance(details_content, dict):
|
||||
try:
|
||||
# 只取部分关键信息或确保可序列化
|
||||
if "status_code" in details_content:
|
||||
processed_detail["status_code_in_validation"] = details_content["status_code"]
|
||||
# 可以添加其他从 details_content 中提取的可序列化字段
|
||||
except TypeError:
|
||||
# 如果 details 无法完全序列化,更新消息
|
||||
processed_detail["message"] = f"{vp_obj.message} (Details not fully serializable)"
|
||||
elif details_content: # 如果 details 不是字典但存在
|
||||
processed_detail["details_type"] = type(details_content).__name__
|
||||
|
||||
vps_details_for_output.append(processed_detail)
|
||||
|
||||
# 如果 finalize_step_result 已经被调用,self.message 可能已经更新
|
||||
# 否则,这里的逻辑会再次尝试整合 (如果 self.message 仍然为空)
|
||||
current_message = self.message
|
||||
if not current_message and self.validation_points:
|
||||
failed_vp_messages = [
|
||||
vp_obj.message for vp_obj in self.validation_points
|
||||
if isinstance(vp_obj, ValidationResult) and not vp_obj.passed and vp_obj.message
|
||||
]
|
||||
if failed_vp_messages:
|
||||
current_message = "; ".join(failed_vp_messages)
|
||||
|
||||
api_op_dict = self.api_operation_spec # Changed from self.api_operation
|
||||
if isinstance(api_op_dict, BaseEndpoint):
|
||||
api_op_dict = api_op_dict.model_dump()
|
||||
|
||||
return {
|
||||
"step_name": self.step_name,
|
||||
"description": self.description,
|
||||
"lookup_key": str(self.lookup_key) if self.lookup_key is not None else None,
|
||||
"resolved_endpoint": self.resolved_endpoint,
|
||||
"status": self.status.value,
|
||||
"message": current_message, # 使用当前或整合后的消息
|
||||
"duration_seconds": f"{self.duration:.4f}",
|
||||
"timestamp": time.strftime('%Y-%m-%dT%H:%M:%S%z', time.localtime(self.timestamp)),
|
||||
"validation_points": vps_details_for_output,
|
||||
"api_call_curl": getattr(getattr(self, 'api_call_detail', None), 'curl_command', 'N/A'),
|
||||
"api_operation_spec": api_op_dict, # <--- 添加此行
|
||||
"request_details": self.request_details,
|
||||
"extracted_outputs": {k: str(v)[:200] + '...' if isinstance(v, (str, bytes)) and len(v) > 200 else v
|
||||
for k, v in self.extracted_outputs.items()},
|
||||
"context_after_step_summary": {
|
||||
k: str(v)[:50] + '...' if isinstance(v, str) and len(v) > 50 else (
|
||||
type(v).__name__ if not isinstance(v, (str, int, float, bool, list, dict, type(None))) else v
|
||||
) for k,v in (self.context_after_step or {}).items()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
class ExecutedStageResult:
|
||||
"""存储整个API测试阶段执行后的结果。"""
|
||||
class Status(str, Enum):
|
||||
PASSED = "通过"
|
||||
FAILED = "失败"
|
||||
SKIPPED = "跳过" # 如果整个阶段因is_applicable_to_api_group返回False或其他原因被跳过
|
||||
PENDING = "处理中" # 新增状态:表示阶段正在处理中
|
||||
ERROR = "执行错误" # <--- 新增 ERROR 状态
|
||||
|
||||
def __init__(self,
|
||||
stage_id: str,
|
||||
stage_name: str,
|
||||
api_group_metadata: Optional[Dict[str, Any]] = None,
|
||||
description: Optional[str] = None, # <--- 添加 description 参数
|
||||
tags: Optional[List[str]] = None, # <-- Added tags parameter
|
||||
overall_status: Status = Status.PENDING,
|
||||
message: str = ""):
|
||||
self.stage_id = stage_id
|
||||
self.stage_name = stage_name
|
||||
self.description = description
|
||||
self.api_group_metadata = api_group_metadata
|
||||
self.tags = tags if tags is not None else [] # <-- Store tags
|
||||
self.overall_status = overall_status
|
||||
self.message = message
|
||||
self.executed_steps: List[ExecutedStageStepResult] = [] # 确保初始化为空列表
|
||||
self.final_context: Optional[Dict[str, Any]] = None
|
||||
# executed_steps_count 应该是一个属性,或者在 to_dict 中计算
|
||||
self.start_time: datetime = datetime.now() # Corrected
|
||||
self.end_time: Optional[datetime] = None # Corrected type hint
|
||||
self.duration: float = 0.0
|
||||
|
||||
def add_step_result(self, step_result: ExecutedStageStepResult):
|
||||
self.executed_steps.append(step_result)
|
||||
|
||||
def finalize_stage_result(self, final_context: Optional[Dict[str, Any]] = None):
|
||||
self.end_time = datetime.now() # Corrected
|
||||
self.duration = (self.end_time - self.start_time).total_seconds()
|
||||
self.final_context = final_context
|
||||
|
||||
if not self.executed_steps and self.overall_status == ExecutedStageResult.Status.SKIPPED:
|
||||
# 如果没有执行任何步骤且状态是初始的 SKIPPED,则保持
|
||||
if not self.message: self.message = "此阶段没有执行任何步骤,被跳过。"
|
||||
elif any(step.status == ExecutedStageResult.Status.ERROR for step in self.executed_steps):
|
||||
self.overall_status = ExecutedStageResult.Status.FAILED # 步骤执行错误导致阶段失败
|
||||
if not self.message: self.message = "一个或多个步骤执行时发生内部错误。"
|
||||
elif any(step.status == ExecutedStageResult.Status.FAILED for step in self.executed_steps):
|
||||
self.overall_status = ExecutedStageResult.Status.FAILED
|
||||
if not self.message: self.message = "一个或多个步骤验证失败。"
|
||||
elif all(step.status == ExecutedStageResult.Status.SKIPPED for step in self.executed_steps) and self.executed_steps:
|
||||
self.overall_status = ExecutedStageResult.Status.SKIPPED # 所有步骤都跳过了
|
||||
if not self.message: self.message = "所有步骤均被跳过。"
|
||||
elif all(step.status == ExecutedStageResult.Status.PASSED or step.status == ExecutedStageResult.Status.SKIPPED for step in self.executed_steps) and \
|
||||
any(step.status == ExecutedStageResult.Status.PASSED for step in self.executed_steps) :
|
||||
self.overall_status = ExecutedStageResult.Status.PASSED # 至少一个通过,其他是跳过或通过
|
||||
if not self.message: self.message = "阶段执行成功。"
|
||||
else: # 其他情况,例如没有步骤但状态不是 SKIPPED (不应发生),或者混合状态未被明确处理
|
||||
if self.executed_steps: # 如果有步骤,但没有明确成功或失败
|
||||
self.overall_status = ExecutedStageResult.Status.FAILED
|
||||
self.message = self.message or "阶段执行结果不明确,默认标记为失败。"
|
||||
# else: 状态保持为初始的 SKIPPED,message也应该在之前设置了
|
||||
|
||||
def to_dict(self) -> Dict[str, Any]:
|
||||
# 对 final_context 进行处理,避免过大或敏感信息直接输出
|
||||
processed_context = {}
|
||||
if self.final_context:
|
||||
for k, v in self.final_context.items():
|
||||
if isinstance(v, (str, bytes)) and len(v) > 200: # 截断长字符串
|
||||
processed_context[k] = str(v)[:200] + '...'
|
||||
elif isinstance(v, (dict, list)): # 对于字典和列表,只显示键或少量元素
|
||||
processed_context[k] = f"Type: {type(v).__name__}, Keys/Count: {len(v)}"
|
||||
else:
|
||||
processed_context[k] = v
|
||||
|
||||
return {
|
||||
"stage_id": self.stage_id,
|
||||
"stage_name": self.stage_name,
|
||||
"description": self.description, # <--- 添加 description 到输出
|
||||
"api_group_name": self.api_group_metadata.get("name", "N/A"),
|
||||
"tags": self.tags, # <-- Added tags to output
|
||||
"overall_status": self.overall_status.value,
|
||||
"duration_seconds": f"{self.duration:.2f}",
|
||||
"start_time": self.start_time.strftime('%Y-%m-%dT%H:%M:%S%z'),
|
||||
"end_time": self.end_time.strftime('%Y-%m-%dT%H:%M:%S%z') if self.end_time else None,
|
||||
"message": self.message,
|
||||
"executed_steps_count": len(self.executed_steps),
|
||||
"executed_steps": [step.to_dict() for step in self.executed_steps],
|
||||
"final_stage_context_summary": processed_context # 可选: 输出处理后的上下文摘要
|
||||
}
|
||||
@@ -3,6 +3,7 @@ from typing import Any, Dict, Optional, List, Tuple, Type, Union
|
||||
import logging
|
||||
from .utils import schema_utils
|
||||
from pydantic import BaseModel, Field
|
||||
from .utils.context_utils import serialize_context_recursively
|
||||
|
||||
class TestSeverity(Enum):
|
||||
"""测试用例的严重程度"""
|
||||
@@ -28,11 +29,13 @@ class ValidationResult:
|
||||
self.details = details or {} # 其他详细信息,如实际值、期望值等
|
||||
|
||||
def to_dict(self) -> Dict[str, Any]:
|
||||
"""将 ValidationResult 对象转换为字典。"""
|
||||
"""将 ValidationResult 对象转换为字典,并递归序列化其 details 字段。"""
|
||||
# Recursively serialize details to handle nested objects like DMSEndpoint
|
||||
serialized_details = serialize_context_recursively(self.details) if self.details else {}
|
||||
return {
|
||||
"passed": self.passed,
|
||||
"message": self.message,
|
||||
"details": self.details
|
||||
"details": serialized_details
|
||||
}
|
||||
|
||||
def __repr__(self):
|
||||
|
||||
@@ -24,7 +24,8 @@ from pydantic import BaseModel, Field, create_model, HttpUrl # Added HttpUrl for
|
||||
from pydantic.networks import EmailStr
|
||||
from pydantic.types import Literal # Explicitly import Literal
|
||||
|
||||
from .input_parser.parser import InputParser, BaseEndpoint, YAPIEndpoint, SwaggerEndpoint, ParsedYAPISpec, ParsedSwaggerSpec, ParsedAPISpec, DMSEndpoint, ParsedDMSSpec
|
||||
from .input_parser.parser import InputParser, YAPIEndpoint, SwaggerEndpoint, ParsedYAPISpec, ParsedSwaggerSpec, ParsedAPISpec, DMSEndpoint, ParsedDMSSpec
|
||||
from .input_parser.parser import BaseEndpoint, DMSEndpoint
|
||||
from .api_caller.caller import APICaller, APIRequest, APIResponse, APICallDetail # Ensure APICallDetail is imported
|
||||
from .json_schema_validator.validator import JSONSchemaValidator
|
||||
from .test_framework_core import ValidationResult, TestSeverity, APIRequestContext, APIResponseContext, BaseAPITestCase
|
||||
@@ -74,20 +75,26 @@ class ExecutedTestCaseResult:
|
||||
self.timestamp = datetime.datetime.now()
|
||||
|
||||
def to_dict(self) -> Dict[str, Any]:
|
||||
message=""
|
||||
if self.message:
|
||||
message = self.message
|
||||
message = self.message
|
||||
if not message and self.validation_points:
|
||||
# Revert to dictionary access since self.validation_points contains dicts
|
||||
failed_messages = [vp.get("message") for vp in self.validation_points if isinstance(vp, dict) and not vp.get("passed") and vp.get("message")]
|
||||
if failed_messages:
|
||||
message = "; ".join(failed_messages)
|
||||
else:
|
||||
message= ";".join([vp.message for vp in self.validation_points])
|
||||
# Fallback message if no specific failure messages are available
|
||||
message = "One or more validation points failed without a detailed message." if self.status == self.Status.FAILED else "All validation points passed."
|
||||
|
||||
return {
|
||||
"test_case_id": self.test_case_id,
|
||||
"test_case_name": self.test_case_name,
|
||||
"test_case_severity": self.test_case_severity.name, # 使用枚举名称
|
||||
"test_case_severity": self.test_case_severity.name,
|
||||
"status": self.status.value,
|
||||
"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]
|
||||
# The list already contains dictionaries, so just return it
|
||||
"validation_points": self.validation_points
|
||||
}
|
||||
|
||||
class TestResult: # 原来的 TestResult 被重构为 EndpointExecutionResult
|
||||
@@ -342,14 +349,8 @@ class TestSummary:
|
||||
return data
|
||||
|
||||
def to_json(self, pretty=True) -> str:
|
||||
def custom_serializer(obj):
|
||||
"""JSON serializer for objects not serializable by default json code"""
|
||||
if isinstance(obj, BaseEndpoint):
|
||||
return obj.to_dict()
|
||||
raise TypeError(f"Object of type {type(obj).__name__} is not JSON serializable")
|
||||
|
||||
indent = 4 if pretty else None
|
||||
return json.dumps(self.to_dict(), indent=indent, ensure_ascii=False, default=custom_serializer)
|
||||
indent = 2 if pretty else None
|
||||
return json.dumps(self.to_dict(), indent=indent, ensure_ascii=False)
|
||||
|
||||
def print_summary_to_console(self): # Renamed from print_summary
|
||||
# (Implementation can be more detailed based on the new stats)
|
||||
@@ -1076,7 +1077,7 @@ class APITestOrchestrator:
|
||||
test_case_name=test_case_instance.name,
|
||||
test_case_severity=test_case_instance.severity,
|
||||
status=ExecutedTestCaseResult.Status.FAILED,
|
||||
validation_points=validation_results,
|
||||
validation_points=[vp.to_dict() for vp in validation_results],
|
||||
message=f"请求预校验失败: {'; '.join(failure_messages)}",
|
||||
duration=tc_duration
|
||||
)
|
||||
@@ -1131,7 +1132,7 @@ class APITestOrchestrator:
|
||||
test_case_name=test_case_instance.name,
|
||||
test_case_severity=test_case_instance.severity,
|
||||
status=final_status,
|
||||
validation_points=validation_results,
|
||||
validation_points=[vp.to_dict() for vp in validation_results],
|
||||
duration=tc_duration
|
||||
)
|
||||
|
||||
@@ -1150,7 +1151,7 @@ class APITestOrchestrator:
|
||||
test_case_name=tc_name_for_log,
|
||||
test_case_severity=tc_severity_for_log,
|
||||
status=ExecutedTestCaseResult.Status.ERROR,
|
||||
validation_points=validation_results, # Ensure validation_results is defined (it is, at the start of the function)
|
||||
validation_points=[vp.to_dict() for vp in validation_results], # Ensure validation_results is defined (it is, at the start of the function)
|
||||
message=f"测试用例执行时发生内部错误 (可能在实例化期间): {str(e)}",
|
||||
duration=tc_duration
|
||||
)
|
||||
@@ -1486,6 +1487,16 @@ class APITestOrchestrator:
|
||||
def run_test_for_endpoint(self, endpoint: Union[YAPIEndpoint, SwaggerEndpoint, DMSEndpoint],
|
||||
global_api_spec: Union[ParsedYAPISpec, ParsedSwaggerSpec, ParsedDMSSpec]
|
||||
) -> TestResult:
|
||||
# 检查是否为仅场景测试的端点,如果是则跳过独立测试
|
||||
if isinstance(endpoint, DMSEndpoint) and hasattr(endpoint, 'test_mode') and endpoint.test_mode == 'scenario_only':
|
||||
self.logger.info(f"跳过对仅场景测试端点的独立测试: {endpoint.method} {endpoint.path}")
|
||||
result = TestResult(
|
||||
endpoint_id=f"{endpoint.method}_{endpoint.path}",
|
||||
endpoint_name=endpoint.title or f"{endpoint.method} {endpoint.path}"
|
||||
)
|
||||
result.overall_status = TestResult.Status.SKIPPED
|
||||
result.message = "此端点标记为仅在场景中测试 (test_mode='scenario_only')"
|
||||
return result
|
||||
endpoint_id = f"{getattr(endpoint, 'method', 'GET').upper()} {getattr(endpoint, 'path', '/')}"
|
||||
endpoint_name = getattr(endpoint, 'title', '') or getattr(endpoint, 'summary', '') or endpoint_id
|
||||
|
||||
@@ -2141,7 +2152,8 @@ class APITestOrchestrator:
|
||||
stage_id=stage_id,
|
||||
stage_name=stage_name,
|
||||
description=stage_instance.description,
|
||||
api_group_metadata=stage_instance.current_api_group_metadata, # Changed from api_group_metadata
|
||||
api_group_metadata=stage_instance.current_api_group_metadata,
|
||||
apis_in_group=stage_instance.apis_in_group, # 传递端点对象列表
|
||||
tags=stage_instance.tags
|
||||
)
|
||||
|
||||
@@ -2267,32 +2279,56 @@ class APITestOrchestrator:
|
||||
request_context=request_context_for_assertion
|
||||
)
|
||||
|
||||
for i, assertion_func in enumerate(step_definition.response_assertions):
|
||||
assertion_name = getattr(assertion_func, '__name__', f"custom_assertion_{i+1}")
|
||||
try:
|
||||
self.logger.debug(f"{step_log_prefix}: 执行断言 '{assertion_name}'")
|
||||
val_res = assertion_func(response_context_for_assertion, stage_context)
|
||||
step_validation_points.append(val_res)
|
||||
if not val_res.passed:
|
||||
current_step_result.status = ExecutedStageStepResult.Status.FAILED
|
||||
self.logger.warning(f"{step_log_prefix}: 断言 '{assertion_name}' 失败: {val_res.message}")
|
||||
except Exception as assert_exc:
|
||||
current_step_result.status = ExecutedStageStepResult.Status.ERROR
|
||||
errMsg = f"断言 '{assertion_name}' 执行错误: {assert_exc}"
|
||||
step_validation_points.append(ValidationResult(passed=False, message=errMsg, details={"error": str(assert_exc)}))
|
||||
self.logger.error(f"{step_log_prefix}: {errMsg}", exc_info=True)
|
||||
|
||||
current_step_result.validation_points = step_validation_points # 修复:存储 ValidationResult 对象本身
|
||||
# Use a separate list for this step's validation points
|
||||
step_validation_results: List[ValidationResult] = []
|
||||
|
||||
if current_step_result.status != ExecutedStageStepResult.Status.ERROR:
|
||||
# Assertions are callables that return ValidationResult
|
||||
for assertion in step_definition.response_assertions:
|
||||
try:
|
||||
validation_result = assertion(response_context_for_assertion, stage_context)
|
||||
step_validation_results.append(validation_result)
|
||||
except Exception as e_assert:
|
||||
self.logger.error(f"{step_log_prefix}: Assertion function '{getattr(assertion, '__name__', 'N/A')}' raised an exception: {e_assert}", exc_info=True)
|
||||
failed_vr = ValidationResult(passed=False, message=f"Assertion function raised an unhandled exception: {e_assert}")
|
||||
step_validation_results.append(failed_vr)
|
||||
|
||||
# Check status codes
|
||||
if step_definition.expected_status_codes:
|
||||
status_code_vr = self._validate_status_code(
|
||||
actual_code=response_context_for_assertion.status_code,
|
||||
expected_codes=step_definition.expected_status_codes
|
||||
)
|
||||
step_validation_results.append(status_code_vr)
|
||||
|
||||
current_step_result.validation_points = [vp.to_dict() for vp in step_validation_results]
|
||||
|
||||
if any(not vp['passed'] for vp in current_step_result.validation_points):
|
||||
current_step_result.status = ExecutedStageStepResult.Status.FAILED
|
||||
|
||||
# Store API call details
|
||||
if api_call_detail_obj:
|
||||
if hasattr(api_call_detail_obj, 'model_dump') and callable(api_call_detail_obj.model_dump):
|
||||
current_step_result.api_call_details = api_call_detail_obj.model_dump()
|
||||
elif hasattr(api_call_detail_obj, 'dict') and callable(api_call_detail_obj.dict):
|
||||
current_step_result.api_call_details = api_call_detail_obj.dict()
|
||||
else:
|
||||
# Fallback if it's some other object, though it should be APICallDetail
|
||||
current_step_result.api_call_details = str(api_call_detail_obj)
|
||||
else:
|
||||
current_step_result.api_call_details = {}
|
||||
|
||||
self.logger.debug(f"{step_log_prefix}: 提取输出到上下文. Map: {step_definition.outputs_to_context}")
|
||||
response_data_for_extraction = {
|
||||
self._extract_outputs_to_context(
|
||||
response_data={
|
||||
"json_content": api_response_obj.json_content,
|
||||
"headers": api_response_obj.headers,
|
||||
"status_code": api_response_obj.status_code
|
||||
}
|
||||
self._extract_outputs_to_context(response_data_for_extraction, step_definition.outputs_to_context, stage_context, current_step_name)
|
||||
current_step_result.context_after_step = copy.deepcopy(stage_context)
|
||||
},
|
||||
outputs_map=step_definition.outputs_to_context,
|
||||
stage_context=stage_context,
|
||||
step_name_for_log=current_step_name
|
||||
)
|
||||
current_step_result.context_after_step = copy.deepcopy(stage_context)
|
||||
|
||||
except Exception as step_exec_exc:
|
||||
current_step_result.status = ExecutedStageStepResult.Status.ERROR
|
||||
@@ -2627,3 +2663,14 @@ class APITestOrchestrator:
|
||||
summary.finalize_summary()
|
||||
return summary, parsed_spec
|
||||
|
||||
def _validate_status_code(self, actual_code: int, expected_codes: List[int]) -> ValidationResult:
|
||||
"""Helper to validate the HTTP status code."""
|
||||
if actual_code in expected_codes:
|
||||
return ValidationResult(passed=True, message=f"响应状态码 {actual_code} 符合预期。")
|
||||
else:
|
||||
return ValidationResult(
|
||||
passed=False,
|
||||
message=f"响应状态码不匹配。预期: {expected_codes}, 实际: {actual_code}",
|
||||
details={"expected": expected_codes, "actual": actual_code}
|
||||
)
|
||||
|
||||
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,27 @@
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
def serialize_context_recursively(context: Any, _path: str = "root") -> Any:
|
||||
"""
|
||||
Recursively traverses a data structure (dict, list) and converts any object
|
||||
with a to_dict() method into its dictionary representation.
|
||||
Includes logging to trace the serialization process.
|
||||
"""
|
||||
if hasattr(context, 'to_dict') and callable(context.to_dict):
|
||||
logger.debug(f"Serializing object of type {type(context).__name__} at path: {_path}")
|
||||
# If the object itself is serializable, serialize it and then process its dict representation
|
||||
return serialize_context_recursively(context.to_dict(), _path)
|
||||
|
||||
if isinstance(context, dict):
|
||||
logger.debug(f"Serializing dict at path: {_path}")
|
||||
return {k: serialize_context_recursively(v, f"{_path}.{k}") for k, v in context.items()}
|
||||
|
||||
if isinstance(context, list):
|
||||
logger.debug(f"Serializing list at path: {_path}")
|
||||
return [serialize_context_recursively(i, f"{_path}[{idx}]") for idx, i in enumerate(context)]
|
||||
|
||||
logger.debug(f"Returning primitive at path: {_path}, type: {type(context).__name__}")
|
||||
# Return primitives and other JSON-serializable types as-is
|
||||
return context
|
||||
@@ -0,0 +1,114 @@
|
||||
"""
|
||||
This module contains the DataGenerator class for creating test data from JSON schemas.
|
||||
"""
|
||||
import logging
|
||||
import datetime
|
||||
import uuid
|
||||
from typing import Dict, Any, Optional, List
|
||||
|
||||
class DataGenerator:
|
||||
"""
|
||||
Generates test data based on a JSON Schema.
|
||||
"""
|
||||
def __init__(self, logger_param: Optional[logging.Logger] = None):
|
||||
"""
|
||||
Initializes the data generator.
|
||||
Args:
|
||||
logger_param: Optional logger instance. If not provided, a module-level logger is used.
|
||||
"""
|
||||
self.logger = logger_param or logging.getLogger(__name__)
|
||||
|
||||
def generate_data_from_schema(self, schema: Dict[str, Any],
|
||||
context_name: Optional[str] = None,
|
||||
operation_id: Optional[str] = None) -> Any:
|
||||
"""
|
||||
Generates test data from a JSON Schema.
|
||||
This method was extracted and generalized from APITestOrchestrator.
|
||||
|
||||
Args:
|
||||
schema: The JSON schema to generate data from.
|
||||
context_name: A name for the context (e.g., 'requestBody'), for logging.
|
||||
operation_id: The operation ID, for logging.
|
||||
|
||||
Returns:
|
||||
Generated data that conforms to the schema.
|
||||
"""
|
||||
log_prefix = f"[{operation_id}] " if operation_id else ""
|
||||
context_log = f" (context: {context_name})" if context_name else ""
|
||||
|
||||
if not schema or not isinstance(schema, dict):
|
||||
self.logger.debug(f"{log_prefix}generate_data_from_schema: Invalid or empty schema provided{context_log}: {schema}")
|
||||
return None
|
||||
|
||||
# Handle schema composition keywords
|
||||
if 'oneOf' in schema or 'anyOf' in schema:
|
||||
schemas_to_try = schema.get('oneOf') or schema.get('anyOf')
|
||||
if schemas_to_try and isinstance(schemas_to_try, list) and schemas_to_try:
|
||||
self.logger.debug(f"{log_prefix}Processing oneOf/anyOf, selecting the first schema for{context_log}")
|
||||
return self.generate_data_from_schema(schemas_to_try[0], context_name, operation_id)
|
||||
|
||||
if 'allOf' in schema:
|
||||
merged_schema = {}
|
||||
for sub_schema in schema.get('allOf', []):
|
||||
merged_schema.update(sub_schema)
|
||||
self.logger.debug(f"{log_prefix}Processing allOf, merging schemas for{context_log}")
|
||||
schema = merged_schema
|
||||
|
||||
# Use example or default values if available
|
||||
if 'example' in schema:
|
||||
self.logger.debug(f"{log_prefix}Using 'example' value from schema for{context_log}: {schema['example']}")
|
||||
return schema['example']
|
||||
if 'default' in schema:
|
||||
self.logger.debug(f"{log_prefix}Using 'default' value from schema for{context_log}: {schema['default']}")
|
||||
return schema['default']
|
||||
|
||||
schema_type = schema.get('type')
|
||||
|
||||
if schema_type == 'object':
|
||||
result = {}
|
||||
properties = schema.get('properties', {})
|
||||
self.logger.debug(f"{log_prefix}Generating object data for{context_log}. Properties: {list(properties.keys())}")
|
||||
for prop_name, prop_schema in properties.items():
|
||||
nested_context = f"{context_name}.{prop_name}" if context_name else prop_name
|
||||
result[prop_name] = self.generate_data_from_schema(prop_schema, nested_context, operation_id)
|
||||
|
||||
additional_properties = schema.get('additionalProperties')
|
||||
if isinstance(additional_properties, dict):
|
||||
self.logger.debug(f"{log_prefix}Generating an example property for additionalProperties for{context_log}")
|
||||
result['additionalProp1'] = self.generate_data_from_schema(additional_properties, f"{context_name}.additionalProp1", operation_id)
|
||||
return result
|
||||
|
||||
elif schema_type == 'array':
|
||||
items_schema = schema.get('items', {})
|
||||
min_items = schema.get('minItems', 1)
|
||||
self.logger.debug(f"{log_prefix}Generating array data for{context_log}. Items schema: {items_schema}, minItems: {min_items}")
|
||||
|
||||
num_items_to_generate = max(1, min_items)
|
||||
generated_array = []
|
||||
for i in range(num_items_to_generate):
|
||||
item_context = f"{context_name}[{i}]" if context_name else f"array_item[{i}]"
|
||||
generated_array.append(self.generate_data_from_schema(items_schema, item_context, operation_id))
|
||||
return generated_array
|
||||
|
||||
elif schema_type == 'string':
|
||||
string_format = schema.get('format', '')
|
||||
if 'enum' in schema and schema['enum']: return schema['enum'][0]
|
||||
if string_format == 'date': return datetime.date.today().isoformat()
|
||||
if string_format == 'date-time': return datetime.datetime.now().isoformat()
|
||||
if string_format == 'email': return 'test@example.com'
|
||||
if string_format == 'uuid': return str(uuid.uuid4())
|
||||
return 'example_string'
|
||||
|
||||
elif schema_type in ['number', 'integer']:
|
||||
minimum = schema.get('minimum')
|
||||
if minimum is not None: return minimum
|
||||
return 0 if schema_type == 'integer' else 0.0
|
||||
|
||||
elif schema_type == 'boolean':
|
||||
return schema.get('default', False)
|
||||
|
||||
elif schema_type == 'null':
|
||||
return None
|
||||
|
||||
self.logger.warning(f"{log_prefix}Unsupported schema type '{schema_type}' in {context_log}. Schema: {schema}")
|
||||
return None
|
||||
Reference in New Issue
Block a user