fix:crud流程测试修复

This commit is contained in:
gongwenxin
2025-07-12 19:53:21 +08:00
parent 25789568a2
commit 6fb15f1840
33 changed files with 26857 additions and 52894 deletions
+25 -9
View File
@@ -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.
+37 -216
View File
@@ -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 # 可选: 输出处理后的上下文摘要
}
+5 -2
View File
@@ -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):
+87 -40
View File
@@ -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}
)
@@ -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