fix:stage_group

This commit is contained in:
gongwenxin
2025-06-05 18:29:04 +08:00
parent cf0df24530
commit b72406df99
15 changed files with 5303 additions and 5235 deletions
+359 -286
View File
@@ -2,431 +2,504 @@ import time
import uuid
import logging
import re
from typing import List, Dict, Any, Optional, Callable
from typing import List, Dict, Any, Optional, Callable, Union
from ddms_compliance_suite.stage_framework import BaseAPIStage, StageStepDefinition, APIOperationSpec, ExecutedStageResult, ExecutedStageStepResult
from ddms_compliance_suite.test_framework_core import ValidationResult, APIResponseContext
from ddms_compliance_suite.input_parser.parser import ParsedAPISpec
# =====================================================================================
# USER CONFIGURATION: RESOURCE KEYWORD
# =====================================================================================
# 请用户根据实际要测试的资源修改此关键字,例如 "用户", "订单", "测井曲线" 等。
# 这个关键字将用于在API规范的端点标题中模糊查找相关的操作。
RESOURCE_KEYWORD = "地质单元" # <-- 【【【请修改这里!】】】
# =====================================================================================
from ddms_compliance_suite.input_parser.parser import ParsedAPISpec, YAPIEndpoint, SwaggerEndpoint
# --- Action Keywords for Discovery (Usually these can remain as is) ---
CREATE_ACTION_KEYWORDS = ["添加", "新建", "创建"]
LIST_ACTION_KEYWORDS = ["列表查询", "查询列表", "获取列表", "分页查询"]
DETAIL_ACTION_KEYWORDS = ["查询详情", "获取详情", "根据ID获取"]
UPDATE_ACTION_KEYWORDS = ["修改", "更新"]
DELETE_ACTION_KEYWORDS = ["删除"]
CREATE_ACTION_KEYWORDS = ["add", "create", "new", "添加", "新建", "创建"]
LIST_ACTION_KEYWORDS = ["list", "query", "search", "getall", "getlist", "列表查询", "查询列表", "获取列表", "分页查询"]
DETAIL_ACTION_KEYWORDS = ["detail", "getbyid", "getone", "查询详情", "获取详情", "根据ID获取"]
UPDATE_ACTION_KEYWORDS = ["update", "edit", "put", "修改", "更新"]
DELETE_ACTION_KEYWORDS = ["delete", "remove", "删除"]
# --- Default values that might come from config or context in a real scenario ---
# --- 这些值也可能需要根据您的API进行调整 ---
DEFAULT_DMS_INSTANCE_CODE_PLACEHOLDER = "your_dms_instance_code" # 示例占位符
DEFAULT_API_VERSION_PLACEHOLDER = "1.0.0" # 示例占位符
DEFAULT_TENANT_ID_PLACEHOLDER = "your-tenant-id" # 示例占位符
DEFAULT_AUTHORIZATION_PLACEHOLDER = "Bearer your-test-token" # 示例占位符
# --- Default values that might come from config or context ---
DEFAULT_DMS_INSTANCE_CODE_PLACEHOLDER = "your_dms_instance_code"
DEFAULT_API_VERSION_PLACEHOLDER = "1.0.0"
DEFAULT_TENANT_ID_PLACEHOLDER = "your-tenant-id"
DEFAULT_AUTHORIZATION_PLACEHOLDER = "Bearer your-test-token"
# --- Custom Assertion Helper Functions & Context Manipulators ---
# 【【【重要提示】】】: 下面的这些辅助函数中的JSON路径 (如 'data.list', 'wellCommonName', 'dsid')
# 是基于 '井筒API示例_simple.json' 的结构。
# 当您修改 RESOURCE_KEYWORD 并适配您自己的API时,您【必须】检查并修改这些路径以匹配您API的实际响应结构。
# --- Helper to navigate JSON path ---
def _get_value_by_path(data: Optional[Dict[str, Any]], path_str: Optional[str]):
if data is None or path_str is None or not path_str:
return data
def find_and_extract_id_by_name(response_ctx: APIResponseContext, stage_ctx: dict) -> ValidationResult:
"""
(通用版) 在列表响应中通过唯一名称查找新创建的资源,
验证其存在,并将其唯一标识符(例如 'id', 'dsid', 'uuid')提取到阶段上下文中。
需要用户在 'outputs_to_context' 中配置如何从响应中提取这个 'id'
此函数主要用于验证是否能找到特定名称的条目。
实际的ID提取最好通过 StageStepDefinition 的 'outputs_to_context' 来完成,因为它更灵活。
此函数假设:
1. 'unique_resource_name' 在 stage_ctx 中。
2. 响应体中有一个列表,其路径是 'data.list' (这可能需要修改!)。
3. 列表中的每个条目是一个字典,并且包含一个 'name_field_in_list' 指定的字段用于匹配名称 (需要用户配置或硬编码修改)。
4. 找到的条目包含一个 'id_field_in_list' 指定的字段作为其唯一ID (需要用户配置或硬编码修改)。
"""
logger = logging.getLogger(__name__)
unique_name_to_find = stage_ctx.get("unique_resource_name")
if not unique_name_to_find:
return ValidationResult(passed=False, message="上下文中未找到 'unique_resource_name'。无法查找已创建的资源。")
# 【【【请用户根据实际API修改下面的JSON路径和字段名】】】
LIST_PATH_IN_RESPONSE = "data.list" # 例如: "items", "data", "result.records"
NAME_FIELD_IN_LIST_ITEM = "wellCommonName" # 例如: "name", "title", "username"
ID_FIELD_IN_LIST_ITEM = "dsid" # 例如: "id", "uuid", "_id"
# 【【【修改结束】】】
response_data = response_ctx.json_content
# Helper to navigate path
def _get_value_by_path(data, path_str):
current = data
try:
for part in path_str.split('.'):
if isinstance(current, dict) and part in current:
current = current[part]
elif isinstance(current, list) and part.isdigit() and 0 <= int(part) < len(current):
current = current[int(part)]
match_list_index = re.fullmatch(r"([a-zA-Z_][a-zA-Z0-9_]*)\[(\d+)\]", part)
if match_list_index:
list_name, index_str = match_list_index.groups()
index = int(index_str)
if isinstance(current, dict) and list_name in current and isinstance(current[list_name], list) and 0 <= index < len(current[list_name]):
current = current[list_name][index]
else: return None
elif isinstance(current, list) and part.isdigit():
idx = int(part)
if 0 <= idx < len(current):
current = current[idx]
else:
return None
elif isinstance(current, dict) and part in current:
current = current[part]
else:
return None
except (TypeError, ValueError, IndexError, KeyError) as e:
logging.getLogger(__name__).debug(f"Error navigating path '{path_str}': {e}")
return None
return current
resource_list = _get_value_by_path(response_data, LIST_PATH_IN_RESPONSE)
# --- Custom Assertion Helper Functions & Context Manipulators ---
# These functions now read field names and paths from stage_ctx,
# which should be populated by the Stage based on its configuration.
def find_resource_in_list_and_extract_id(response_ctx: APIResponseContext, stage_ctx: dict) -> ValidationResult:
logger = logging.getLogger(__name__)
unique_name_to_find = stage_ctx.get("unique_resource_name")
list_path = stage_ctx.get("cfg_list_path_in_listresponse", "data.list")
name_field = stage_ctx.get("cfg_name_field_in_listitem", "name")
id_field = stage_ctx.get("cfg_id_field_in_listitem", "id")
if not unique_name_to_find:
return ValidationResult(passed=False, message="Context error: 'unique_resource_name' not found in stage_context.")
response_data = response_ctx.json_content
resource_list = _get_value_by_path(response_data, list_path)
if not isinstance(resource_list, list):
return ValidationResult(passed=False, message=f"响应格式错误:期望路径 '{LIST_PATH_IN_RESPONSE}' 返回一个列表,实际得到 {type(resource_list)}")
return ValidationResult(passed=False, message=f"Response format error: Expected path '{list_path}' to be a list, got {type(resource_list)}.")
found_item = None
for item in resource_list:
if isinstance(item, dict) and item.get(NAME_FIELD_IN_LIST_ITEM) == unique_name_to_find:
if isinstance(item, dict) and item.get(name_field) == unique_name_to_find:
found_item = item
break
if not found_item:
return ValidationResult(passed=False, message=f"在列表响应中未找到名称为 '{unique_name_to_find}' (字段: {NAME_FIELD_IN_LIST_ITEM}) 的资源。")
return ValidationResult(passed=False, message=f"Resource not found: Name '{unique_name_to_find}' (field: {name_field}) not found in list at path '{list_path}'.")
resource_id = found_item.get(ID_FIELD_IN_LIST_ITEM)
if not resource_id:
return ValidationResult(passed=False, message=f"找到名称为 '{unique_name_to_find}' 的资源,但它缺少ID字段 '{ID_FIELD_IN_LIST_ITEM}'")
resource_id = found_item.get(id_field)
if resource_id is None:
return ValidationResult(passed=False, message=f"Resource found by name, but it lacks the ID field '{id_field}'.")
# 将找到的ID存入上下文,变量名可由outputs_to_context覆盖
stage_ctx["created_resource_id"] = resource_id
logger.info(f"成功在列表中找到资源 '{unique_name_to_find}' 并提取其ID ({ID_FIELD_IN_LIST_ITEM}): {resource_id}")
return ValidationResult(passed=True, message=f"找到资源 '{unique_name_to_find}' 并提取ID: {resource_id}。建议使用 outputs_to_context 获取ID。")
logger.info(f"Successfully found resource '{unique_name_to_find}' in list and extracted its ID ({id_field}): {resource_id}.")
return ValidationResult(passed=True, message=f"Found resource '{unique_name_to_find}' by name (field '{name_field}'). ID ('{id_field}': {resource_id}) available in context. Prefer 'outputs_to_context' for extraction.")
def check_resource_details(response_ctx: APIResponseContext, stage_ctx: dict) -> ValidationResult:
"""
(通用版) 验证获取到的资源详情。
假设 'created_resource_id''unique_resource_name' 在 stage_ctx 中。
假设响应直接就是资源对象,或者在 'data.list[0]''data' 中 (需要用户调整)。
"""
# 【【【请用户根据实际API修改下面的JSON路径和字段名】】】
# 响应中资源对象的位置。留空表示响应体本身就是资源对象。
# 例如:"data.list[0]" (如果详情接口返回带列表的结构), "data" (如果返回带data包装的结构)
RESOURCE_OBJECT_PATH_IN_RESPONSE = "data.list[0]"
NAME_FIELD_IN_DETAIL = "wellCommonName"
ID_FIELD_IN_DETAIL = "dsid"
# 【【【修改结束】】】
resource_object_path = stage_ctx.get("cfg_resource_object_path_in_detailresponse", "")
name_field = stage_ctx.get("cfg_name_field_in_detailresponse", "name")
id_field = stage_ctx.get("cfg_id_field_in_detailresponse", "id")
feature_field_name = stage_ctx.get("cfg_feature_field_name_for_validation")
expected_feature_value = stage_ctx.get("unique_feature_value")
created_id = stage_ctx.get("created_resource_id")
initial_name = stage_ctx.get("unique_resource_name")
response_data = response_ctx.json_content
def _get_value_by_path(data, path_str): # Duplicated helper for clarity, could be refactored
if not path_str: return data # No path, return whole data
current = data
for part in path_str.split('.'):
# Basic list index handling like "list[0]"
match_list_index = re.fullmatch(r"([a-zA-Z_][a-zA-Z0-9_]*)\[(\d+)\]", part)
if match_list_index:
list_name, index_str = match_list_index.groups()
index = int(index_str)
if isinstance(current, dict) and list_name in current and isinstance(current[list_name], list) and 0 <= index < len(current[list_name]):
current = current[list_name][index]
else: return None
elif isinstance(current, dict) and part in current:
current = current[part]
else: return None
return current
resource_data = _get_value_by_path(response_data, RESOURCE_OBJECT_PATH_IN_RESPONSE)
resource_data = _get_value_by_path(response_ctx.json_content, resource_object_path)
if not isinstance(resource_data, dict):
return ValidationResult(passed=False, message=f"响应格式错误:期望路径 '{RESOURCE_OBJECT_PATH_IN_RESPONSE}' 返回一个对象,实际得到 {type(resource_data)}")
return ValidationResult(passed=False, message=f"Response format error: Expected path '{resource_object_path}' to be an object, got {type(resource_data)}.")
if resource_data.get(ID_FIELD_IN_DETAIL) != created_id:
return ValidationResult(passed=False, message=f"ID不匹配。期望 {created_id}, 得到 {resource_data.get(ID_FIELD_IN_DETAIL)} (字段: {ID_FIELD_IN_DETAIL})。")
if NAME_FIELD_IN_DETAIL and resource_data.get(NAME_FIELD_IN_DETAIL) != initial_name: # Name check is optional
return ValidationResult(passed=False, message=f"名称不匹配。期望 '{initial_name}', 得到 '{resource_data.get(NAME_FIELD_IN_DETAIL)}' (字段: {NAME_FIELD_IN_DETAIL})。")
validation_results = []
if resource_data.get(id_field) != created_id:
validation_results.append(ValidationResult(passed=False, message=f"ID mismatch. Expected '{created_id}', got '{resource_data.get(id_field)}' (field: {id_field})."))
else:
validation_results.append(ValidationResult(passed=True, message=f"ID match: '{created_id}' (field: {id_field})."))
return ValidationResult(passed=True, message=f"资源详情 (ID: {created_id}) 校验成功。")
if name_field and resource_data.get(name_field) != initial_name:
validation_results.append(ValidationResult(passed=False, message=f"Name mismatch. Expected '{initial_name}', got '{resource_data.get(name_field)}' (field: {name_field})."))
elif name_field:
validation_results.append(ValidationResult(passed=True, message=f"Name match: '{initial_name}' (field: {name_field})."))
if feature_field_name and expected_feature_value is not None:
actual_feature_value = resource_data.get(feature_field_name)
if actual_feature_value != expected_feature_value:
validation_results.append(ValidationResult(passed=False, message=f"Feature field '{feature_field_name}' mismatch. Expected '{expected_feature_value}', got '{actual_feature_value}'."))
else:
validation_results.append(ValidationResult(passed=True, message=f"Feature field '{feature_field_name}' match: '{expected_feature_value}'."))
overall_passed = all(vr.passed for vr in validation_results)
combined_message = "Resource details check: " + "; ".join([vr.message for vr in validation_results])
return ValidationResult(passed=overall_passed, message=combined_message, details=[vr.to_dict() for vr in validation_results])
def check_resource_updated_details(response_ctx: APIResponseContext, stage_ctx: dict) -> ValidationResult:
"""
(通用版) 验证更新后的资源详情。
假设 'created_resource_id''updated_resource_name' 在 stage_ctx 中。
"""
# 【【【请用户根据实际API修改下面的JSON路径和字段名】】】
RESOURCE_OBJECT_PATH_IN_RESPONSE = "data.list[0]"
NAME_FIELD_IN_DETAIL = "wellCommonName"
ID_FIELD_IN_DETAIL = "dsid"
# 【【【修改结束】】】
resource_object_path = stage_ctx.get("cfg_resource_object_path_in_updateresponse", stage_ctx.get("cfg_resource_object_path_in_detailresponse", ""))
name_field = stage_ctx.get("cfg_name_field_in_detailresponse", "name")
id_field = stage_ctx.get("cfg_id_field_in_detailresponse", "id")
feature_field_name = stage_ctx.get("cfg_feature_field_name_for_validation")
expected_updated_feature_value = stage_ctx.get("updated_feature_value")
created_id = stage_ctx.get("created_resource_id")
updated_name = stage_ctx.get("updated_resource_name")
# (Code is very similar to check_resource_details, could be refactored if desired)
response_data = response_ctx.json_content
def _get_value_by_path(data, path_str):
if not path_str: return data
current = data
for part in path_str.split('.'):
match_list_index = re.fullmatch(r"([a-zA-Z_][a-zA-Z0-9_]*)\[(\d+)\]", part)
if match_list_index:
list_name, index_str = match_list_index.groups()
index = int(index_str)
if isinstance(current, dict) and list_name in current and isinstance(current[list_name], list) and 0 <= index < len(current[list_name]):
current = current[list_name][index]
else: return None
elif isinstance(current, dict) and part in current:
current = current[part]
else: return None
return current
resource_data = _get_value_by_path(response_data, RESOURCE_OBJECT_PATH_IN_RESPONSE)
resource_data = _get_value_by_path(response_ctx.json_content, resource_object_path)
if not isinstance(resource_data, dict):
return ValidationResult(passed=False, message=f"更新后详情响应格式错误:期望路径 '{RESOURCE_OBJECT_PATH_IN_RESPONSE}' 返回一个对象,实际得到 {type(resource_data)}")
return ValidationResult(passed=False, message=f"Updated detail response format error: Expected path '{resource_object_path}' to be an object, got {type(resource_data)}.")
if resource_data.get(ID_FIELD_IN_DETAIL) != created_id:
return ValidationResult(passed=False, message=f"更新后ID不匹配。期望 {created_id}, 得到 {resource_data.get(ID_FIELD_IN_DETAIL)}")
if NAME_FIELD_IN_DETAIL and resource_data.get(NAME_FIELD_IN_DETAIL) != updated_name:
return ValidationResult(passed=False, message=f"更新后名称不匹配。期望 '{updated_name}', 得到 '{resource_data.get(NAME_FIELD_IN_DETAIL)}'")
validation_results = []
if resource_data.get(id_field) != created_id:
validation_results.append(ValidationResult(passed=False, message=f"Updated ID mismatch. Expected '{created_id}', got '{resource_data.get(id_field)}'."))
else:
validation_results.append(ValidationResult(passed=True, message=f"Updated ID match: '{created_id}'."))
return ValidationResult(passed=True, message=f"更新后资源详情 (ID: {created_id}) 校验成功,名称为 '{updated_name}'")
if name_field and resource_data.get(name_field) != updated_name:
validation_results.append(ValidationResult(passed=False, message=f"Updated name mismatch. Expected '{updated_name}', got '{resource_data.get(name_field)}'."))
elif name_field:
validation_results.append(ValidationResult(passed=True, message=f"Updated name match: '{updated_name}'."))
if feature_field_name and expected_updated_feature_value is not None:
actual_feature_value = resource_data.get(feature_field_name)
if actual_feature_value != expected_updated_feature_value:
validation_results.append(ValidationResult(passed=False, message=f"Updated feature field '{feature_field_name}' mismatch. Expected '{expected_updated_feature_value}', got '{actual_feature_value}'."))
else:
validation_results.append(ValidationResult(passed=True, message=f"Updated feature field '{feature_field_name}' match: '{expected_updated_feature_value}'."))
overall_passed = all(vr.passed for vr in validation_results)
combined_message = "Updated resource details check: " + "; ".join([vr.message for vr in validation_results])
return ValidationResult(passed=overall_passed, message=combined_message, details=[vr.to_dict() for vr in validation_results])
class KeywordDrivenCRUDStage(BaseAPIStage):
id = "keyword_driven_crud_example"
name = "Keyword-Driven Generic CRUD Stage Example"
class GenericCRUDValidationStage(BaseAPIStage):
id = "generic_crud_validation_stage"
name = "Generic CRUD Validation Stage"
description = (
"Demonstrates a CRUD (Create, List, Read, Update, Delete) flow. "
"This stage dynamically finds API operations based on a configurable RESOURCE_KEYWORD "
"and predefined action keywords (e.g., '添加', '查询列表'). "
"IMPORTANT: User MUST configure RESOURCE_KEYWORD at the top of this file. "
"Request bodies, response paths in assertions, and 'outputs_to_context' "
"are EXAMPLES based on '井筒API示例_simple.json' and WILL LIKELY NEED MODIFICATION "
"to match your specific API's structure."
"Performs a generic CRUD (Create, List, Read, Update, Delete) flow. "
"It tries to dynamically discover API operations based on common keywords and HTTP methods. "
"Field names for IDs, names, features, and JSON paths for list/detail objects are configurable "
"via class attributes or can be set in `before_stage` based on `api_group_name`."
)
tags = ["crud", "keyword_driven", "example"]
continue_on_failure = False # Set to True if you want to attempt all steps even if one fails
tags = ["crud", "generic_validation"]
continue_on_failure = False
fail_if_not_applicable_to_any_group = False
# This will be populated by is_applicable_to_api_group
discovered_op_keys: dict = {}
# This will be populated in before_stage
# --- Configurable field names and JSON paths ---
cfg_id_field_in_create_response: str = "data.0.dsid"
cfg_name_field_in_create_payload: str = "wellCommonName"
cfg_feature_field_in_create_payload: str = "dataRegion"
cfg_list_path_in_list_response: str = "data.list"
cfg_id_field_in_list_item: str = "dsid"
cfg_name_field_in_list_item: str = "wellCommonName"
cfg_feature_field_in_list_item: str = "dataRegion"
cfg_resource_object_path_in_detail_response: str = "data.list.0"
cfg_id_field_in_detail_response: str = "dsid"
cfg_name_field_in_detail_response: str = "wellCommonName"
cfg_feature_field_in_detail_response: str = "dataRegion"
cfg_resource_object_path_in_update_response: str = "data.list.0"
cfg_path_param_name_for_id: str = "id"
discovered_op_keys: Dict[str, Optional[str]] = {}
steps: list = []
def _find_operation_title_or_id(self, endpoints: list, resource_kw: str, action_kws: list, http_method: str | None = None) -> str | None:
"""Tries to find an endpoint by matching keywords in its title and optionally method."""
self.logger.debug(f"Searching for op with resource='{resource_kw}', actions='{action_kws}', method='{http_method}'")
for ep_obj in endpoints: # ep_obj is YAPIEndpoint or SwaggerEndpoint
# YAPI uses 'title', Swagger often uses 'summary' or 'operationId'
# We prioritize 'title' (from YAPI) then 'summary', then 'operationId' as lookup keys
title_to_check = getattr(ep_obj, 'title', None)
summary_to_check = getattr(ep_obj, 'summary', None)
op_id_to_check = getattr(ep_obj, 'operation_id', None)
def _determine_resource_config(self, api_group_name: Optional[str], global_api_spec: ParsedAPISpec):
self.logger.info(f"Using default field/path configurations for group '{api_group_name}'. Customize by overriding _determine_resource_config or cfg_* attributes.")
pass
# Determine primary text for keyword matching
text_for_matching = title_to_check or summary_to_check or op_id_to_check
if not text_for_matching:
def _find_operation_best_match(self, endpoints: List[Union[YAPIEndpoint, SwaggerEndpoint]],
action_kws: List[str],
target_method: Optional[str] = None,
path_must_contain_id: bool = False,
path_must_not_contain_id: bool = False) -> Optional[str]:
self.logger.debug(f"Attempting to find operation: target_actions='{action_kws}', target_method='{target_method}', path_id_required={path_must_contain_id}, path_id_forbidden={path_must_not_contain_id}")
best_candidate_key: Optional[str] = None
for ep_obj in endpoints:
ep_title = getattr(ep_obj, 'title', 'N/A')
ep_method_actual = getattr(ep_obj, 'method', 'N/A').upper()
ep_path = getattr(ep_obj, 'path', 'N/A')
self.logger.debug(f" Checking endpoint: Title='{ep_title}', Method='{ep_method_actual}', Path='{ep_path}', Type='{type(ep_obj).__name__}'")
# Log all attributes of the endpoint object to see what the parser provided
if isinstance(ep_obj, YAPIEndpoint):
self.logger.debug(f" Raw YAPIEndpoint object attributes for '{ep_title}': {vars(ep_obj)}")
text_to_match = getattr(ep_obj, 'title', None) or \
getattr(ep_obj, 'summary', None) or \
getattr(ep_obj, 'operation_id', None)
if not text_to_match:
self.logger.debug(f" Skipping endpoint Title='{ep_title}', Path='{ep_path}': No text_to_match (title/summary/opId).")
continue
# The key we will use to lookup the endpoint later in the orchestrator
# The orchestrator uses 'title' for YAPI and 'operationId' or (method+path) for Swagger
# For simplicity in this dynamic stage, we will try to return YAPI's 'title' if available and it matches,
# otherwise 'operationId' if available and it matches.
# If using Swagger and 'operationId' is preferred, adjust logic or ensure operationIds are descriptive.
lookup_key = getattr(ep_obj, 'title', None) or \
getattr(ep_obj, 'operation_id', None) or \
f"{ep_method_actual} {ep_path}"
lookup_key_to_return = title_to_check # Default to title (YAPI primary)
text_lower = text_to_match.lower()
text_lower = text_for_matching.lower()
resource_kw_lower = resource_kw.lower()
action_match = any(action_kw.lower() in text_lower for action_kw in action_kws)
self.logger.debug(f" For '{ep_title}': Action match with '{action_kws}'? {action_match}")
if resource_kw_lower in text_lower:
if any(action_kw.lower() in text_lower for action_kw in action_kws):
current_ep_method = getattr(ep_obj, 'method', '').upper()
if http_method and current_ep_method == http_method.upper():
self.logger.info(f"Discovered endpoint: Key='{lookup_key_to_return or op_id_to_check}', MatchedText='{text_for_matching}' (for resource='{resource_kw}', action='{action_kws}', method='{http_method}')")
return lookup_key_to_return or op_id_to_check # Return title or operationId
elif not http_method:
self.logger.info(f"Discovered endpoint: Key='{lookup_key_to_return or op_id_to_check}', MatchedText='{text_for_matching}' (for resource='{resource_kw}', action='{action_kws}', any method)")
return lookup_key_to_return or op_id_to_check
self.logger.warning(f"No operation found for resource='{resource_kw}', actions='{action_kws}', method='{http_method}'")
return None
method_match = (not target_method) or (ep_method_actual == target_method.upper())
self.logger.debug(f" For '{ep_title}': Method match with '{target_method}'? {method_match} (Endpoint method: {ep_method_actual})")
def is_applicable_to_api_group(self, api_group_name: str | None, global_api_spec: ParsedAPISpec) -> bool:
self.logger.info(f"Checking applicability of '{self.name}' (RESOURCE_KEYWORD: '{RESOURCE_KEYWORD}') for API group '{api_group_name}'.")
path_contains_id_param = False
if isinstance(ep_obj, YAPIEndpoint):
self.logger.debug(f" For YAPI Endpoint '{ep_title}': Checking req_params for path ID: {ep_obj.req_params}")
for param in ep_obj.req_params: # YAPI req_params are path parameters
if isinstance(param, dict):
param_name = param.get('name', 'UnnamedParam')
if param_name == self.cfg_path_param_name_for_id or 'id' in param_name.lower():
path_contains_id_param = True
self.logger.debug(f" Found ID path parameter in YAPI req_params: '{param_name}'")
break
elif isinstance(ep_obj, SwaggerEndpoint):
swagger_params = getattr(ep_obj, 'parameters', None)
self.logger.debug(f" For Swagger Endpoint '{ep_title}': Checking swagger_params for path ID: {swagger_params}")
if isinstance(swagger_params, list):
for param in swagger_params:
if isinstance(param, dict):
param_name = param.get('name', 'UnnamedParam')
param_in = param.get('in', 'N/A')
if param_in == 'path' and \
(param_name == self.cfg_path_param_name_for_id or 'id' in param_name.lower()):
path_contains_id_param = True
self.logger.debug(f" Found ID path parameter in Swagger parameters: '{param_name}'")
break
else:
self.logger.warning(f" For '{ep_title}': Endpoint is of unknown type {type(ep_obj).__name__} for parameter extraction. Assuming no path ID param.")
self.logger.debug(f" For '{ep_title}': Calculated path_contains_id_param={path_contains_id_param} (cfg_path_param_name_for_id='{self.cfg_path_param_name_for_id}')")
path_condition_met = True
if path_must_contain_id and not path_contains_id_param:
path_condition_met = False
if path_must_not_contain_id and path_contains_id_param:
path_condition_met = False
self.logger.debug(f" For '{ep_title}': Calculated path_condition_met={path_condition_met} (required={path_must_contain_id}, forbidden={path_must_not_contain_id})")
if action_match and method_match and path_condition_met:
self.logger.info(f" SUCCESS: Op match for actions '{action_kws}': Key='{lookup_key}', MatchedText='{text_to_match}', Method='{ep_method_actual}', Path='{ep_path}'")
best_candidate_key = lookup_key
break
else:
self.logger.debug(f" Criteria NOT met for '{ep_title}' (Action: {action_match}, Method: {method_match}, PathCond: {path_condition_met}). Continuing search.")
if not best_candidate_key:
self.logger.warning(f" FAILURE: No operation found after checking all endpoints for: actions='{action_kws}', method='{target_method}', path_id_required={path_must_contain_id}, path_id_forbidden={path_must_not_contain_id}")
return best_candidate_key
def is_applicable_to_api_group(self, api_group_name: Optional[str], global_api_spec: ParsedAPISpec) -> bool:
self.logger.info(f"Checking applicability of '{self.name}' for API group '{api_group_name}'.")
if not global_api_spec or not global_api_spec.endpoints:
self.logger.warning(f"'{self.name}' cannot determine applicability: global_api_spec or its endpoints are missing.")
return False
# Reset for this applicability check
self._determine_resource_config(api_group_name, global_api_spec)
self.discovered_op_keys = {}
all_endpoints = global_api_spec.endpoints
endpoints_in_group = self.apis_in_group
self.discovered_op_keys["create"] = self._find_operation_title_or_id(all_endpoints, RESOURCE_KEYWORD, CREATE_ACTION_KEYWORDS, "POST")
self.discovered_op_keys["list"] = self._find_operation_title_or_id(all_endpoints, RESOURCE_KEYWORD, LIST_ACTION_KEYWORDS, "POST") # Assuming list is POST for this example
self.discovered_op_keys["detail"] = self._find_operation_title_or_id(all_endpoints, RESOURCE_KEYWORD, DETAIL_ACTION_KEYWORDS, "GET")
self.discovered_op_keys["update"] = self._find_operation_title_or_id(all_endpoints, RESOURCE_KEYWORD, UPDATE_ACTION_KEYWORDS, "PUT")
self.discovered_op_keys["delete"] = self._find_operation_title_or_id(all_endpoints, RESOURCE_KEYWORD, DELETE_ACTION_KEYWORDS, "DELETE")
self.discovered_op_keys["create"] = self._find_operation_best_match(endpoints_in_group, CREATE_ACTION_KEYWORDS, "POST", path_must_not_contain_id=True)
self.discovered_op_keys["list"] = self._find_operation_best_match(endpoints_in_group, LIST_ACTION_KEYWORDS, None, path_must_not_contain_id=True)
self.discovered_op_keys["detail"] = self._find_operation_best_match(endpoints_in_group, DETAIL_ACTION_KEYWORDS, "GET", path_must_contain_id=True)
self.discovered_op_keys["update"] = self._find_operation_best_match(endpoints_in_group, UPDATE_ACTION_KEYWORDS, "PUT", path_must_contain_id=True)
self.discovered_op_keys["delete"] = self._find_operation_best_match(endpoints_in_group, DELETE_ACTION_KEYWORDS, "DELETE", path_must_contain_id=False)
missing_ops = [op_type for op_type, key_val in self.discovered_op_keys.items() if not key_val]
required_ops = ["create", "detail", "delete"]
missing_required_ops = [op for op in required_ops if not self.discovered_op_keys.get(op)]
if not missing_ops:
self.logger.info(f"'{self.name}' is APPLICABLE for group '{api_group_name}'. All CRUD operations found for '{RESOURCE_KEYWORD}'. Discovered keys: {self.discovered_op_keys}")
if not missing_required_ops:
self.logger.info(f"'{self.name}' is APPLICABLE for group '{api_group_name}'. Required CRUD operations found. Discovered keys: {self.discovered_op_keys}")
return True
else:
self.logger.warning(f"'{self.name}' is NOT APPLICABLE for group '{api_group_name}'. Missing operations for '{RESOURCE_KEYWORD}': {missing_ops}. Discovered keys: {self.discovered_op_keys}")
self.logger.warning(f"'{self.name}' is NOT APPLICABLE for group '{api_group_name}'. Missing required operations: {missing_required_ops}. Discovered keys: {self.discovered_op_keys}")
return False
def before_stage(self, stage_context: dict, global_api_spec: ParsedAPISpec, api_group_name: str | None):
self._determine_resource_config(api_group_name, global_api_spec)
self.logger.info(f"Starting stage '{self.name}' for API group '{api_group_name}'. Discovered op keys: {self.discovered_op_keys}")
run_timestamp = int(time.time())
run_uuid_short = uuid.uuid4().hex[:6]
# Generate unique names for this run, using the configured RESOURCE_KEYWORD
# Ensure RESOURCE_KEYWORD is simple enough for this concatenation.
safe_resource_keyword_part = re.sub(r'\W+', '', RESOURCE_KEYWORD) # Remove non-alphanumeric for safety
unique_name_for_run = f"Test_{safe_resource_keyword_part}_{run_timestamp}_{run_uuid_short}"
unique_name_for_run = f"TestResource_{run_timestamp}_{run_uuid_short}"
updated_name_for_run = f"{unique_name_for_run}_UPDATED"
unique_feature_val = f"Feature_{run_timestamp}"
updated_feature_val = f"{unique_feature_val}_UPDATED"
stage_context["unique_resource_name"] = unique_name_for_run
stage_context["updated_resource_name"] = updated_name_for_run
stage_context["unique_feature_value"] = unique_feature_val
stage_context["updated_feature_value"] = updated_feature_val
stage_context["cfg_list_path_in_listresponse"] = self.cfg_list_path_in_list_response
stage_context["cfg_name_field_in_listitem"] = self.cfg_name_field_in_list_item
stage_context["cfg_id_field_in_listitem"] = self.cfg_id_field_in_list_item
stage_context["cfg_resource_object_path_in_detailresponse"] = self.cfg_resource_object_path_in_detail_response
stage_context["cfg_name_field_in_detailresponse"] = self.cfg_name_field_in_detail_response
stage_context["cfg_id_field_in_detailresponse"] = self.cfg_id_field_in_detail_response
stage_context["cfg_resource_object_path_in_updateresponse"] = self.cfg_resource_object_path_in_update_response
stage_context["cfg_feature_field_name_for_validation"] = self.cfg_feature_field_in_create_payload
# These are placeholders and likely need to be adapted or fetched from config/context
stage_context["dms_instance_code"] = DEFAULT_DMS_INSTANCE_CODE_PLACEHOLDER
stage_context["api_version"] = DEFAULT_API_VERSION_PLACEHOLDER
self.logger.info(f"Initial stage context: {stage_context}")
self.logger.info(f"Initial stage context (with cfg): {stage_context}")
if not all(self.discovered_op_keys.values()):
self.logger.error("Cannot build steps as not all operation keys were discovered. This should have been caught by is_applicable. Stage will have no steps.")
if not self.discovered_op_keys.get("create") or \
not self.discovered_op_keys.get("detail") or \
not self.discovered_op_keys.get("delete"):
self.logger.error("Cannot build steps as not all essential operation keys (create, detail, delete) were discovered. Stage will have no steps.")
self.steps = []
return
# --- Dynamically build steps using discovered keys ---
# 【【【重要提示】】】: 下面的 request_overrides (尤其是 'body') 和 'outputs_to_context'
# 是基于 '井筒API示例_simple.json' (当RESOURCE_KEYWORD="地质单元"时)的结构。
# 当您修改 RESOURCE_KEYWORD 并用于您自己的API时,
# 您【必须】相应地修改这些 'body' 结构和 'outputs_to_context' 中的JSON路径。
self.steps = [
StageStepDefinition(
name=f"Add New {RESOURCE_KEYWORD}",
create_body_data = {
self.cfg_name_field_in_create_payload: "{{stage_context.unique_resource_name}}",
}
if self.cfg_feature_field_in_create_payload:
create_body_data[self.cfg_feature_field_in_create_payload] = "{{stage_context.unique_feature_value}}"
final_create_body = {
"version": "{{stage_context.api_version}}",
"data": [create_body_data]
}
self.steps = []
if self.discovered_op_keys["create"]:
self.steps.append(StageStepDefinition(
name="Create New Resource",
endpoint_spec_lookup_key=self.discovered_op_keys["create"],
request_overrides={
"path_params": {"dms_instance_code": "{{stage_context.dms_instance_code}}"}, # Example path param
"path_params": {"dms_instance_code": "{{stage_context.dms_instance_code}}"},
"headers": {"tenant-id": DEFAULT_TENANT_ID_PLACEHOLDER, "Authorization": DEFAULT_AUTHORIZATION_PLACEHOLDER},
"body": { # EXAMPLE BODY - MODIFY FOR YOUR API
"version": "{{stage_context.api_version}}",
"data": [{
"bsflag": 0, # Field specific to GeoUnit example
"wellCommonName": "{{stage_context.unique_resource_name}}", # Field specific to GeoUnit example
"wellId": f"ExampleWellID_{run_timestamp}", # Field specific to GeoUnit example
"dataRegion": "TEST_REGION" # Field specific to GeoUnit example
}]
}
"body": final_create_body
},
expected_status_codes=[200], # Modify as per your API
# outputs_to_context: {"created_resource_id": "body.data[0].dsid"} # MODIFY FOR YOUR API
),
StageStepDefinition(
name=f"List and Find Created {RESOURCE_KEYWORD}",
expected_status_codes=[200, 201],
outputs_to_context={"created_resource_id": self.cfg_id_field_in_create_response}
))
if self.discovered_op_keys["list"] and self.discovered_op_keys["create"]:
list_query_filter = {
"key": self.cfg_name_field_in_list_item,
"symbol": "=",
"realValue": ["{{stage_context.unique_resource_name}}"]
}
self.steps.append(StageStepDefinition(
name="List and Find Created Resource",
endpoint_spec_lookup_key=self.discovered_op_keys["list"],
request_overrides={
"path_params": { # Example path params
"path_params": {
"dms_instance_code": "{{stage_context.dms_instance_code}}",
"version": "{{stage_context.api_version}}"
},
"headers": {"tenant-id": DEFAULT_TENANT_ID_PLACEHOLDER, "Authorization": DEFAULT_AUTHORIZATION_PLACEHOLDER},
"query_params": {"pageNo": 1, "pageSize": 10}, # Example query params
"body": { # EXAMPLE BODY for a POST list query - MODIFY FOR YOUR API
"query_params": {"pageNo": 1, "pageSize": 10},
"body": {
"isSearchCount": True,
"query": {
"fields": ["dsid", "wellCommonName"], # Example fields - MODIFY
"filter": { # Example filter - MODIFY
"key": "wellCommonName",
"symbol": "=",
"realValue": ["{{stage_context.unique_resource_name}}"]
}
"fields": [self.cfg_id_field_in_list_item, self.cfg_name_field_in_list_item, self.cfg_feature_field_in_list_item],
"filter": list_query_filter
}
}
},
expected_status_codes=[200],
response_assertions=[find_and_extract_id_by_name], # Uses generic helper, ensure its internal paths are also updated!
outputs_to_context={"created_resource_id": "body.data.list[0].dsid"} # EXAMPLE output - MODIFY
),
StageStepDefinition(
name=f"Get Created {RESOURCE_KEYWORD} Details",
response_assertions=[find_resource_in_list_and_extract_id],
outputs_to_context={"found_id_from_list": f"{self.cfg_list_path_in_list_response}.0.{self.cfg_id_field_in_list_item}"}
))
if self.discovered_op_keys["detail"] and self.discovered_op_keys["create"]:
self.steps.append(StageStepDefinition(
name="Get Created Resource Details",
endpoint_spec_lookup_key=self.discovered_op_keys["detail"],
request_overrides={
"path_params": { # Example path params - MODIFY
"path_params": {
"dms_instance_code": "{{stage_context.dms_instance_code}}",
"version": "{{stage_context.api_version}}",
"id": "{{stage_context.created_resource_id}}" # Assumes 'id' is the path param name
self.cfg_path_param_name_for_id: "{{stage_context.created_resource_id}}"
},
"headers": {"tenant-id": DEFAULT_TENANT_ID_PLACEHOLDER, "Authorization": DEFAULT_AUTHORIZATION_PLACEHOLDER}
},
expected_status_codes=[200],
response_assertions=[check_resource_details] # Uses generic helper, ensure its internal paths are also updated!
),
StageStepDefinition(
name=f"Update Created {RESOURCE_KEYWORD}",
endpoint_spec_lookup_key=self.discovered_op_keys["update"],
request_overrides={
"path_params": {"dms_instance_code": "{{stage_context.dms_instance_code}}"}, # Example
"query_params": {"id": "{{stage_context.created_resource_id}}"}, # Example if ID is in query for PUT
"headers": {"tenant-id": DEFAULT_TENANT_ID_PLACEHOLDER, "Authorization": DEFAULT_AUTHORIZATION_PLACEHOLDER},
"body": { # EXAMPLE BODY - MODIFY FOR YOUR API
response_assertions=[check_resource_details]
))
if self.discovered_op_keys["update"] and self.discovered_op_keys["create"]:
update_body_data = {
self.cfg_id_field_in_detail_response: "{{stage_context.created_resource_id}}",
self.cfg_name_field_in_detail_response: "{{stage_context.updated_resource_name}}",
}
if self.cfg_feature_field_in_create_payload:
update_body_data[self.cfg_feature_field_in_create_payload] = "{{stage_context.updated_feature_value}}"
final_update_body = {
"id": "{{stage_context.created_resource_id}}",
"version": "{{stage_context.api_version}}",
"wellCommonName": "{{stage_context.updated_resource_name}}", # Example field
"dataRegion": "TEST_REGION_UPDATED" # Example field
# Add other required fields for your API's update operation
**update_body_data
}
self.steps.append(StageStepDefinition(
name="Update Created Resource",
endpoint_spec_lookup_key=self.discovered_op_keys["update"],
request_overrides={
"path_params": {
"dms_instance_code": "{{stage_context.dms_instance_code}}",
self.cfg_path_param_name_for_id: "{{stage_context.created_resource_id}}"
},
"headers": {"tenant-id": DEFAULT_TENANT_ID_PLACEHOLDER, "Authorization": DEFAULT_AUTHORIZATION_PLACEHOLDER},
"body": final_update_body
},
expected_status_codes=[200],
),
StageStepDefinition(
name=f"Get Updated {RESOURCE_KEYWORD} Details",
))
if self.discovered_op_keys["detail"] and self.discovered_op_keys["update"] and self.discovered_op_keys["create"]:
self.steps.append(StageStepDefinition(
name="Get Updated Resource Details",
endpoint_spec_lookup_key=self.discovered_op_keys["detail"],
request_overrides={
"path_params": {
"dms_instance_code": "{{stage_context.dms_instance_code}}",
"version": "{{stage_context.api_version}}",
"id": "{{stage_context.created_resource_id}}"
self.cfg_path_param_name_for_id: "{{stage_context.created_resource_id}}"
},
"headers": {"tenant-id": DEFAULT_TENANT_ID_PLACEHOLDER, "Authorization": DEFAULT_AUTHORIZATION_PLACEHOLDER}
},
expected_status_codes=[200],
response_assertions=[check_resource_updated_details] # Uses generic helper, check paths!
),
StageStepDefinition(
name=f"Delete Created {RESOURCE_KEYWORD}",
response_assertions=[check_resource_updated_details]
))
if self.discovered_op_keys["delete"] and self.discovered_op_keys["create"]:
self.steps.append(StageStepDefinition(
name="Delete Created Resource",
endpoint_spec_lookup_key=self.discovered_op_keys["delete"],
request_overrides={
"path_params": {"dms_instance_code": "{{stage_context.dms_instance_code}}"}, # Example
"query_params": {"id": "{{stage_context.created_resource_id}}"}, # Example if ID is in query for DELETE
"headers": {"tenant-id": DEFAULT_TENANT_ID_PLACEHOLDER, "Authorization": DEFAULT_AUTHORIZATION_PLACEHOLDER},
"body": { # EXAMPLE BODY for delete (if it takes a body) - MODIFY FOR YOUR API
"version": "{{stage_context.api_version}}",
"data": ["{{stage_context.created_resource_id}}"]
}
"path_params": {
"dms_instance_code": "{{stage_context.dms_instance_code}}",
self.cfg_path_param_name_for_id: "{{stage_context.created_resource_id}}"
},
expected_status_codes=[204], # Or 204 No Content, etc.
),
StageStepDefinition(
name=f"Verify {RESOURCE_KEYWORD} Deletion",
endpoint_spec_lookup_key=self.discovered_op_keys["detail"], # Try to get it again
"headers": {"tenant-id": DEFAULT_TENANT_ID_PLACEHOLDER, "Authorization": DEFAULT_AUTHORIZATION_PLACEHOLDER},
},
expected_status_codes=[200, 204],
))
if self.discovered_op_keys["detail"] and self.discovered_op_keys["delete"] and self.discovered_op_keys["create"]:
self.steps.append(StageStepDefinition(
name="Verify Resource Deletion",
endpoint_spec_lookup_key=self.discovered_op_keys["detail"],
request_overrides={
"path_params": {
"dms_instance_code": "{{stage_context.dms_instance_code}}",
"version": "{{stage_context.api_version}}",
"id": "{{stage_context.created_resource_id}}"
self.cfg_path_param_name_for_id: "{{stage_context.created_resource_id}}"
},
"headers": {"tenant-id": DEFAULT_TENANT_ID_PLACEHOLDER, "Authorization": DEFAULT_AUTHORIZATION_PLACEHOLDER}
},
expected_status_codes=[404], # Expect Not Found
),
]
expected_status_codes=[404],
))
def after_stage(self, stage_result: 'ExecutedStageResult', stage_context: dict, global_api_spec: ParsedAPISpec, api_group_name: str | None):
self.logger.info(f"结束阶段 '{self.name}' (资源关键字: '{RESOURCE_KEYWORD}'). API分组: '{api_group_name}'. 最终状态: {stage_result.overall_status}. 最终上下文: {stage_context}")
def after_stage(self, stage_result: ExecutedStageResult, stage_context: dict, global_api_spec: ParsedAPISpec, api_group_name: str | None):
self.logger.info(f"Finished stage '{self.name}'. API Group: '{api_group_name}'. Final Status: {stage_result.overall_status}. Final Context Keys: {list(stage_context.keys())}")
-228
View File
@@ -1,228 +0,0 @@
from typing import List, Dict, Any, Optional, Callable, Union
import datetime
import logging
from enum import Enum
from .test_framework_core import ValidationResult, APIRequestContext, APIResponseContext
from .api_caller.caller import APICallDetail # 需要APICallDetail来记录每个步骤的调用
class ScenarioStepDefinition:
"""定义API场景中的一个单独步骤。"""
def __init__(self,
name: str,
endpoint_spec_lookup_key: str, # 用于从全局API规范中查找端点定义的键
request_overrides: Optional[Dict[str, Any]] = None,
expected_status_codes: Optional[List[int]] = None,
response_assertions: Optional[List[Callable[[APIResponseContext, Dict[str, Any]], List[ValidationResult]]]] = None,
outputs_to_context: Optional[Dict[str, str]] = None):
"""
Args:
name: 步骤的可读名称。
endpoint_spec_lookup_key: 用于查找端点定义的键 (例如 "METHOD /path" 或 YAPI的_id)。
request_overrides: 覆盖默认请求参数的字典。值可以是占位符,如 "{{scenario_context.user_id}}"
支持的键有: "path_params", "query_params", "headers", "body"
expected_status_codes: 预期的HTTP响应状态码列表。如果为None,则不进行特定状态码检查(除非由response_assertions处理)。
response_assertions: 自定义断言函数列表。每个函数接收 APIResponseContext 和 scenario_context,返回 ValidationResult 列表。
outputs_to_context: 从响应中提取数据到场景上下文的字典。
键是存储到场景上下文中的变量名,值是提取路径 (例如 "response.body.data.id")。
"""
self.name = name
self.endpoint_spec_lookup_key = endpoint_spec_lookup_key
self.request_overrides = request_overrides if request_overrides is not None else {}
self.expected_status_codes = expected_status_codes if expected_status_codes is not None else []
self.response_assertions = response_assertions if response_assertions is not None else []
self.outputs_to_context = outputs_to_context if outputs_to_context is not None else {}
self.logger = logging.getLogger(f"scenario.step.{name}")
class BaseAPIScenario:
"""
API场景测试用例的基类。
用户应继承此类来创建具体的测试场景。
"""
# --- 元数据 (由子类定义) ---
id: str = "base_api_scenario"
name: str = "基础API场景"
description: str = "这是一个基础API场景,应由具体场景继承。"
tags: List[str] = []
steps: List[ScenarioStepDefinition] = [] # 子类需要填充此列表
def __init__(self,
global_api_spec: Dict[str, Any], # 完整的API规范字典 (YAPI/Swagger解析后的原始字典)
parsed_api_endpoints: List[Dict[str, Any]], # 解析后的端点列表,用于通过 lookup_key 查找
llm_service: Optional[Any] = None):
"""
初始化API场景。
Args:
global_api_spec: 完整的API规范字典。
parsed_api_endpoints: 从YAPI/Swagger解析出来的端点对象列表(通常是YAPIEndpoint或SwaggerEndpoint的to_dict()结果)。
这些对象应包含用于匹配 `endpoint_spec_lookup_key` 的字段。
llm_service: APITestOrchestrator 传入的 LLMService 实例 (可选)。
"""
self.global_api_spec = global_api_spec
self.parsed_api_endpoints = parsed_api_endpoints # 用于快速查找
self.llm_service = llm_service
self.logger = logging.getLogger(f"scenario.{self.id}")
self.logger.info(f"API场景 '{self.id}' ({self.name}) 已初始化。")
def _get_endpoint_spec_from_global(self, lookup_key: str) -> Optional[Dict[str, Any]]:
"""
根据提供的 lookup_key 从 self.parsed_api_endpoints 中查找并返回端点定义。
查找逻辑可能需要根据 lookup_key 的格式 (例如, YAPI _id, METHOD /path) 进行调整。
简单实现:假设 lookup_key 是 METHOD /path 或 title。
"""
self.logger.debug(f"尝试为场景步骤查找端点: '{lookup_key}'")
for endpoint_data in self.parsed_api_endpoints:
# 尝试匹配 "METHOD /path" 格式 (常见于SwaggerEndpoint)
method_path_key = f"{str(endpoint_data.get('method', '')).upper()} {endpoint_data.get('path', '')}"
if lookup_key == method_path_key:
self.logger.debug(f"通过 'METHOD /path' ('{method_path_key}') 找到端点。")
return endpoint_data
# 尝试匹配 title (常见于YAPIEndpoint)
if lookup_key == endpoint_data.get('title'):
self.logger.debug(f"通过 'title' ('{endpoint_data.get('title')}') 找到端点。")
return endpoint_data
# 尝试匹配 YAPI 的 _id (如果可用)
if str(lookup_key) == str(endpoint_data.get('_id')): # 转换为字符串以确保比较
self.logger.debug(f"通过 YAPI '_id' ('{endpoint_data.get('_id')}') 找到端点。")
return endpoint_data
# 尝试匹配 Swagger/OpenAPI 的 operationId
if lookup_key == endpoint_data.get('operationId'):
self.logger.debug(f"通过 'operationId' ('{endpoint_data.get('operationId')}') 找到端点。")
return endpoint_data
self.logger.warning(f"未能在 parsed_api_endpoints 中找到 lookup_key 为 '{lookup_key}' 的端点。")
return None
def before_scenario(self, scenario_context: Dict[str, Any]):
"""在场景所有步骤执行前调用 (可选,供子类覆盖)"""
self.logger.debug(f"Hook: before_scenario for '{self.id}'")
pass
def after_scenario(self, scenario_context: Dict[str, Any], scenario_result: 'ExecutedScenarioResult'):
"""在场景所有步骤执行后调用 (可选,供子类覆盖)"""
self.logger.debug(f"Hook: after_scenario for '{self.id}'")
pass
def before_step(self, step_definition: ScenarioStepDefinition, scenario_context: Dict[str, Any]):
"""在每个步骤执行前调用 (可选,供子类覆盖)"""
self.logger.debug(f"Hook: before_step '{step_definition.name}' for '{self.id}'")
pass
def after_step(self, step_definition: ScenarioStepDefinition, step_result: 'ExecutedScenarioStepResult', scenario_context: Dict[str, Any]):
"""在每个步骤执行后调用 (可选,供子类覆盖)"""
self.logger.debug(f"Hook: after_step '{step_definition.name}' for '{self.id}'")
pass
class ExecutedScenarioStepResult:
"""存储单个API场景步骤执行后的结果。"""
class Status(str, Enum):
PASSED = "通过"
FAILED = "失败"
ERROR = "执行错误"
SKIPPED = "跳过"
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,
extracted_outputs: Optional[Dict[str, Any]] = None):
self.step_name = step_name
self.status = status
self.message = message
self.validation_points = validation_points if validation_points is not None else []
self.duration = duration
self.api_call_detail = api_call_detail # 存储此步骤的API调用详情
self.extracted_outputs = extracted_outputs if extracted_outputs is not None else {} # 从此步骤提取并存入上下文的值
self.timestamp = datetime.datetime.now()
def to_dict(self) -> Dict[str, Any]:
return {
"step_name": self.step_name,
"status": self.status.value,
"message": self.message,
"duration_seconds": self.duration,
"timestamp": self.timestamp.isoformat(),
"validation_points": [vp.to_dict() if hasattr(vp, 'to_dict') else {"passed": vp.passed, "message": vp.message, "details": vp.details} for vp in self.validation_points],
"api_call_detail": self.api_call_detail.to_dict() if self.api_call_detail and hasattr(self.api_call_detail, 'to_dict') else None,
"extracted_outputs": self.extracted_outputs
}
class ExecutedScenarioResult:
"""存储整个API场景执行后的结果。"""
class Status(str, Enum):
PASSED = "通过" # 所有步骤都通过
FAILED = "失败" # 任何一个步骤失败或出错
SKIPPED = "跳过" # 整个场景被跳过
def __init__(self,
scenario_id: str,
scenario_name: str,
overall_status: Status = Status.SKIPPED,
message: str = ""):
self.scenario_id = scenario_id
self.scenario_name = scenario_name
self.overall_status = overall_status
self.message = message
self.executed_steps: List[ExecutedScenarioStepResult] = []
self.scenario_context_final_state: Dict[str, Any] = {}
self.start_time = datetime.datetime.now()
self.end_time: Optional[datetime.datetime] = None
def add_step_result(self, result: ExecutedScenarioStepResult):
self.executed_steps.append(result)
def finalize_scenario_result(self, final_context: Dict[str, Any]):
self.end_time = datetime.datetime.now()
self.scenario_context_final_state = final_context
if not self.executed_steps and self.overall_status == ExecutedScenarioResult.Status.SKIPPED:
pass # 保持 SKIPPED
elif any(step.status == ExecutedScenarioStepResult.Status.ERROR for step in self.executed_steps):
self.overall_status = ExecutedScenarioResult.Status.FAILED
if not self.message: self.message = "场景中至少一个步骤执行出错。"
elif any(step.status == ExecutedScenarioStepResult.Status.FAILED for step in self.executed_steps):
self.overall_status = ExecutedScenarioResult.Status.FAILED
if not self.message: self.message = "场景中至少一个步骤失败。"
elif all(step.status == ExecutedScenarioStepResult.Status.SKIPPED for step in self.executed_steps) and self.executed_steps:
self.overall_status = ExecutedScenarioResult.Status.SKIPPED # 如果所有步骤都跳过了
if not self.message: self.message = "场景中的所有步骤都被跳过。"
elif not self.executed_steps: # 没有步骤执行,也不是初始的SKIPPED
self.overall_status = ExecutedScenarioResult.Status.FAILED # 或 ERROR
if not self.message: self.message = "场景中没有步骤被执行。"
else: # 所有步骤都通过
self.overall_status = ExecutedScenarioResult.Status.PASSED
if not self.message: self.message = "场景所有步骤成功通过。"
@property
def duration(self) -> float:
if self.start_time and self.end_time:
return (self.end_time - self.start_time).total_seconds()
return 0.0
def to_dict(self) -> Dict[str, Any]:
return {
"scenario_id": self.scenario_id,
"scenario_name": self.scenario_name,
"overall_status": self.overall_status.value,
"message": self.message,
"duration_seconds": f"{self.duration:.2f}",
"start_time": self.start_time.isoformat(),
"end_time": self.end_time.isoformat() if self.end_time else None,
"executed_steps": [step.to_dict() for step in self.executed_steps],
"scenario_context_final_state": self.scenario_context_final_state # 可能包含敏感信息,按需处理
}
def to_json(self, pretty=True) -> str:
import json # 局部导入
indent = 2 if pretty else None
# 对于 scenario_context_final_state,可能需要自定义序列化器来处理复杂对象
return json.dumps(self.to_dict(), indent=indent, ensure_ascii=False, default=str)
@@ -1,90 +0,0 @@
import os
import importlib.util
import inspect
import logging
from typing import List, Type, Dict, Optional
from .scenario_framework import BaseAPIScenario # 从新的场景框架模块导入
class ScenarioRegistry:
"""
负责发现、加载和管理所有自定义的 BaseAPIScenario 类。
"""
def __init__(self, scenarios_dir: Optional[str] = None):
"""
初始化 ScenarioRegistry。
Args:
scenarios_dir: 存放自定义API场景 (.py 文件) 的目录路径。如果为None,则不进行发现。
"""
self.scenarios_dir = scenarios_dir
self.logger = logging.getLogger(__name__)
self._registry: Dict[str, Type[BaseAPIScenario]] = {}
self._scenario_classes: List[Type[BaseAPIScenario]] = []
if self.scenarios_dir:
self.discover_scenarios()
else:
self.logger.info("ScenarioRegistry 初始化时未提供 scenarios_dir,跳过场景发现。")
def discover_scenarios(self):
"""
扫描指定目录及其所有子目录,动态导入模块,并注册所有继承自 BaseAPIScenario 的类。
"""
if not self.scenarios_dir or not os.path.isdir(self.scenarios_dir):
self.logger.warning(f"API场景目录不存在或不是一个目录: {self.scenarios_dir}")
return
self.logger.info(f"开始从目录 '{self.scenarios_dir}' 及其子目录发现API场景...")
found_count = 0
for root_dir, _, files in os.walk(self.scenarios_dir):
for filename in files:
if filename.endswith(".py") and not filename.startswith("__"):
module_name = filename[:-3]
file_path = os.path.join(root_dir, filename)
try:
spec = importlib.util.spec_from_file_location(module_name, file_path)
if spec and spec.loader:
module = importlib.util.module_from_spec(spec)
spec.loader.exec_module(module)
self.logger.debug(f"成功导入API场景模块: {module_name}{file_path}")
for name, obj in inspect.getmembers(module):
if inspect.isclass(obj) and issubclass(obj, BaseAPIScenario) and obj is not BaseAPIScenario:
if not hasattr(obj, 'id') or not obj.id:
self.logger.error(f"API场景类 '{obj.__name__}' 在文件 '{file_path}' 中缺少有效的 'id' 属性,已跳过注册。")
continue
if obj.id in self._registry:
self.logger.warning(f"发现重复的API场景 ID: '{obj.id}' (来自类 '{obj.__name__}' in {file_path})。之前的定义将被覆盖。")
self._registry[obj.id] = obj
# 更新 _scenario_classes 列表
existing_class_indices = [i for i, sc_class in enumerate(self._scenario_classes) if sc_class.id == obj.id]
if existing_class_indices:
for index in sorted(existing_class_indices, reverse=True):
del self._scenario_classes[index]
self._scenario_classes.append(obj)
found_count += 1
self.logger.info(f"已注册API场景: '{obj.id}' ({getattr(obj, 'name', 'N/A')}) 来自类 '{obj.__name__}' (路径: {file_path})")
else:
self.logger.error(f"无法为文件 '{file_path}' 创建模块规范 (用于API场景)。")
except ImportError as e:
self.logger.error(f"导入API场景模块 '{module_name}''{file_path}' 失败: {e}", exc_info=True)
except AttributeError as e:
self.logger.error(f"在API场景模块 '{module_name}' ({file_path}) 中查找场景时出错: {e}", exc_info=True)
except Exception as e:
self.logger.error(f"处理API场景文件 '{file_path}' 时发生未知错误: {e}", exc_info=True)
# 场景通常不需要像单个测试用例那样排序执行顺序,除非有特定需求
# 如果需要,可以添加类似 execution_order 的属性并排序
# self._scenario_classes.sort(key=lambda sc_class: (getattr(sc_class, 'execution_order', 100), sc_class.__name__))
self.logger.info(f"API场景发现完成。总共注册了 {len(self._registry)} 个独特的API场景 (基于ID)。发现并加载了 {len(self._scenario_classes)} 个API场景类。")
def get_scenario_by_id(self, scenario_id: str) -> Optional[Type[BaseAPIScenario]]:
"""根据ID获取已注册的API场景类。"""
return self._registry.get(scenario_id)
def get_all_scenario_classes(self) -> List[Type[BaseAPIScenario]]:
"""获取所有已注册的API场景类列表。"""
return list(self._scenario_classes) # 返回副本
+341 -84
View File
@@ -6,6 +6,7 @@ import time
from typing import List, Dict, Any, Callable, Optional, Union
from enum import Enum
from datetime import datetime
from dataclasses import dataclass, field
# Add Pydantic BaseModel for APIOperationSpec
from pydantic import BaseModel
@@ -25,14 +26,145 @@ except ImportError:
logger = logging.getLogger(__name__)
# 定义 APIOperationSpec
class APIOperationSpec(BaseModel):
# Forward declaration for type hinting if ParsedAPISpec is used directly
ParsedAPISpec = "ParsedAPISpec"
@dataclass
class APIOperationSpec:
"""封装了从全局API规范中解析出来的特定API操作的详细信息"""
method: str
path: str
spec: Dict[str, Any] # 原始API端点定义字典
spec: Dict[str, Any] # API操作的完整OpenAPI/Swagger规范字典部分
operation_id: Optional[str] = None
summary: Optional[str] = None
description: Optional[str] = None
tags: List[str] = field(default_factory=list)
@dataclass
class StageStepDefinition:
"""定义测试阶段中的单个步骤"""
name: str
endpoint_spec_lookup_key: str # 用于在API规范中查找此步骤对应的端点
request_overrides: Dict[str, Any] = field(default_factory=dict)
expected_status_codes: List[int] = field(default_factory=lambda: [200, 201])
# Corrected type hint for response_assertions
response_assertions: List[Callable[[Any, Dict[str, Any]], ValidationResult]] = field(default_factory=list) # response_context, stage_context -> ValidationResult
outputs_to_context: Dict[str, str] = field(default_factory=dict) # 从响应提取到阶段上下文的映射
description: Optional[str] = None
order: int = 0 # ADDED order attribute
class ExecutedStageStepResult:
"""存储单个测试阶段步骤的执行结果"""
class Status(str, Enum):
PENDING = "待定"
PASSED = "通过"
FAILED = "失败"
ERROR = "执行错误"
SKIPPED = "跳过"
def __init__(self,
step_name: str,
status: Status = Status.PENDING,
message: str = "",
lookup_key: Optional[str] = None,
resolved_endpoint: Optional[str] = None,
request_details: Optional[Dict[str, Any]] = None,
api_call_details: Optional[Dict[str, Any]] = None, # 存储 APICallDetail.to_dict() 的结果
validation_points: Optional[List[Dict[str, Any]]] = None, # 存储 ValidationResult.to_dict() 的结果列表
duration_seconds: float = 0.0,
context_after_step: Optional[Dict[str, Any]] = None,
description: Optional[str] = None):
self.step_name = step_name
self.description = description
self.status = status
self.message = message
self.lookup_key = lookup_key
self.resolved_endpoint = resolved_endpoint
self.request_details = request_details if request_details is not None else {}
self.api_call_details = api_call_details if api_call_details is not None else {}
self.validation_points = validation_points if validation_points is not None else []
self.duration_seconds = duration_seconds
self.context_after_step = context_after_step if context_after_step is not None else {}
self.timestamp: datetime = datetime.now()
def to_dict(self) -> Dict[str, Any]:
return {
"step_name": self.step_name,
"description": self.description,
"status": self.status.value,
"message": self.message,
"lookup_key": self.lookup_key,
"resolved_endpoint": self.resolved_endpoint,
"duration_seconds": f"{self.duration_seconds:.4f}",
"timestamp": self.timestamp.isoformat(),
"request_details": self.request_details,
"api_call_details": self.api_call_details,
"validation_points": self.validation_points,
"context_snapshot_after_step": self.context_after_step # 可以选择是否完整记录上下文
}
class ExecutedStageResult:
"""存储整个测试阶段的执行结果"""
class Status(str, Enum):
PENDING = "待定"
PASSED = "通过"
FAILED = "失败"
ERROR = "执行错误"
SKIPPED = "跳过"
def __init__(self,
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
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_snapshot: Optional[Dict[str, Any]] = None
self.start_time: datetime = datetime.now()
self.end_time: Optional[datetime] = None
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): # Renamed from finalize_result
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 {}
# 确定最终状态的逻辑可以放在这里,或者由编排器在调用后设置
if not self.executed_steps and self.overall_status == ExecutedStageResult.Status.PENDING:
self.overall_status = ExecutedStageResult.Status.SKIPPED # 如果没有步骤执行,且状态未被其他方式设置
self.message = self.message or "阶段中没有步骤被执行或所有步骤被跳过。"
# 更复杂的最终状态判定可能需要编排器逻辑
def to_dict(self) -> Dict[str, Any]:
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
"overall_status": self.overall_status.value,
"message": self.message,
"duration_seconds": f"{self.duration:.2f}",
"start_time": self.start_time.isoformat(),
"end_time": self.end_time.isoformat() if self.end_time else None,
"executed_steps": [step.to_dict() for step in self.executed_steps],
"final_context_snapshot": self.final_context_snapshot
}
# 默认的操作类型关键字映射
# 键是标准化的操作类型,值是可能出现在API标题中的关键字列表
@@ -45,31 +177,6 @@ DEFAULT_OPERATION_KEYWORDS: Dict[str, List[str]] = {
# 可以根据需要添加更多通用操作类型
}
class StageStepDefinition:
"""定义API测试阶段中的单个步骤。"""
def __init__(self,
name: str,
endpoint_spec_lookup_key: Union[str, Dict[str, str]], # 例如 "GET /pets/{petId}" 或 {"method": "GET", "path": "/pets/{petId}"} 或操作类型 "add"
description: Optional[str] = None, # <--- 添加 description 参数
request_overrides: Optional[Dict[str, Any]] = None,
expected_status_codes: Optional[List[int]] = None,
response_assertions: Optional[List[Callable[[APIResponseContext, Dict[str, Any]], List[ValidationResult]]]] = None,
outputs_to_context: Optional[Dict[str, str]] = None,
order: int = 0): # 新增执行顺序
self.name = name
self.endpoint_spec_lookup_key = endpoint_spec_lookup_key
self.description = description # <--- 设置 description 属性
self.request_overrides = request_overrides or {}
self.expected_status_codes = expected_status_codes or []
self.response_assertions = response_assertions or []
self.outputs_to_context = outputs_to_context or {}
self.order = order
if not isinstance(self.endpoint_spec_lookup_key, (str, dict)):
raise ValueError("StageStepDefinition: endpoint_spec_lookup_key must be a string (operation type or method/path) or a dict {'method': 'X', 'path': 'Y'}")
class BaseAPIStage:
"""
API测试阶段的基类。
@@ -84,22 +191,33 @@ class BaseAPIStage:
# 由子类定义,表示此Stage中的API调用步骤
steps: List[StageStepDefinition] = []
# 新增: 控制当此阶段不适用于任何API分组时,是否标记为失败
fail_if_not_applicable_to_any_group: bool = False
# 新增: 控制当一个步骤失败/错误时,是否继续执行后续步骤
continue_on_failure: bool = False
def __init__(self,
api_group_metadata: Dict[str, Any],
apis_in_group: List[Dict[str, Any]], # 当前分组内所有API的定义列表 (字典格式)
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):
self.logger = logging.getLogger(f"{__name__}.{self.__class__.__name__}")
self.current_api_group_metadata = api_group_metadata
self.current_apis_in_group = apis_in_group # 这些应该是已经解析好的API定义字典
self.api_group_metadata = api_group_metadata # Let's ensure this common name is also available.
self.apis_in_group = apis_in_group # CORRECTED attribute name
self.global_api_spec: ParsedAPISpec = global_api_spec
self.llm_service = llm_service
self.global_api_spec = global_api_spec # 保留 Optional[ParsedAPISpec]
self._operation_keywords = operation_keywords or DEFAULT_OPERATION_KEYWORDS
self._operation_keywords = operation_keywords if operation_keywords is not None else {} # CORRECTED assignment
self._matched_endpoints: Dict[str, Dict[str, Any]] = {} # 存储按操作类型匹配到的端点定义
self._match_api_endpoints_in_group() # 初始化时自动匹配
# Calling _match_api_endpoints_in_group() here might be problematic if it relies on
# subclass-specific setup or if it's intended to be called by the subclass.
# For now, let's comment it out from the base __init__ to avoid potential errors
# until its role and safety here are confirmed.
# self._match_api_endpoints_in_group()
# 确保子类定义了ID
if not self.id:
@@ -107,22 +225,127 @@ class BaseAPIStage:
self.logger.warning(f"BaseAPIStage subclass '{self.__class__.__name__}' does not have an 'id' attribute defined. Defaulting to class name: '{self.id}'. It is recommended to set a unique id.")
# 对步骤进行排序
if self.steps: # Ensure steps exist before trying to sort
self.steps = sorted(self.steps, key=lambda s: s.order)
else:
self.steps = []
def is_applicable_to_api_group(self, api_group_metadata: Dict[str, Any], apis_in_group: List[Dict[str, Any]]) -> bool:
def get_api_spec_for_operation(self,
lookup_key: str,
global_api_spec: ParsedAPISpec, # 确保类型正确
api_group_name: Optional[str] = None
) -> Optional[APIOperationSpec]:
"""
判断此测试阶段是否适用于给定的API分组
子类可以重写此方法以实现更复杂的适用性逻辑
根据查找键从提供的API规范中获取特定API操作的详细信息
这个方法需要子类根据其查找逻辑来实现,或者提供一个通用的基于operationId或path+method的查找
Args:
api_group_metadata: API分组的元数据 (例如 YAPI category name/id 或 Swagger tag name/description)。
apis_in_group: 该分组内的API端点定义列表
lookup_key: 用于查找API操作的键 (例如 operationId, "METHOD /path", 或自定义键)。
global_api_spec: 完整的已解析API规范对象
api_group_name: (可选) 当前API分组的名称,用于更精确的查找或作用域限定。
Returns:
True 如果此阶段适用于该API分组,否则 False。
APIOperationSpec 对象如果找到,否则 None。
"""
return True # 默认应用于所有分组
self.logger.debug(f"Attempting to find API spec for lookup_key='{lookup_key}', api_group='{api_group_name}'")
# 尝试基于 operationId 查找 (如果 lookup_key 看起来像 operationId)
# 简单的启发式:不包含空格和斜杠的键可能是 operationId
if ' ' not in lookup_key and '/' not in lookup_key:
for endpoint in global_api_spec.endpoints:
endpoint_dict = endpoint.to_dict() # 将YAPIEndpoint或SwaggerEndpoint转换为标准化字典
op_id = endpoint_dict.get('operationId')
if op_id == lookup_key:
# 检查此端点是否属于当前API分组 (如果提供了api_group_name)
if api_group_name:
tags = endpoint_dict.get('tags', [])
if api_group_name not in tags:
self.logger.debug(f"Endpoint with operationId '{lookup_key}' found, but not in group '{api_group_name}'. Tags: {tags}")
continue # 不在当前组,继续查找
self.logger.info(f"Found API for operationId '{lookup_key}' (group: {api_group_name}). Path: {endpoint_dict.get('path')}")
return APIOperationSpec(
method=endpoint_dict.get('method','').upper(),
path=endpoint_dict.get('path',''),
spec=endpoint_dict, # 传递整个端点字典作为规范
operation_id=op_id,
summary=endpoint_dict.get('summary'),
description=endpoint_dict.get('description'),
tags=endpoint_dict.get('tags', [])
)
# 尝试基于 "METHOD /path" 格式的 lookup_key 查找
# (这是一个更通用的查找方式,但可能需要更仔细的路径匹配逻辑)
parts = lookup_key.split(' ', 1)
if len(parts) == 2:
method_to_find = parts[0].upper()
path_to_find = parts[1]
for endpoint in global_api_spec.endpoints:
endpoint_dict = endpoint.to_dict()
if endpoint_dict.get('method','').upper() == method_to_find and endpoint_dict.get('path','') == path_to_find:
if api_group_name:
tags = endpoint_dict.get('tags', [])
if api_group_name not in tags:
self.logger.debug(f"Endpoint '{lookup_key}' found, but not in group '{api_group_name}'. Tags: {tags}")
continue
self.logger.info(f"Found API for method/path '{lookup_key}' (group: {api_group_name}).")
return APIOperationSpec(
method=method_to_find,
path=path_to_find,
spec=endpoint_dict,
operation_id=endpoint_dict.get('operationId'),
summary=endpoint_dict.get('summary'),
description=endpoint_dict.get('description'),
tags=endpoint_dict.get('tags', [])
)
self.logger.warning(f"Could not find API operation spec for lookup_key: '{lookup_key}' (api_group: '{api_group_name}') using default search logic (operationId or METHOD /path). Consider overriding get_api_spec_for_operation in your Stage class for custom lookup logic.")
return None
# --- 生命周期钩子 ---
def before_stage(self, stage_context: Dict[str, Any], global_api_spec: ParsedAPISpec, api_group_name: Optional[str]):
"""在测试阶段所有步骤执行之前调用。"""
self.logger.debug(f"Executing before_stage for stage '{self.id}', group '{api_group_name}'. Initial context: {stage_context}")
pass
def after_stage(self, stage_result: ExecutedStageResult, stage_context: Dict[str, Any], global_api_spec: ParsedAPISpec, api_group_name: Optional[str]):
"""在测试阶段所有步骤执行完毕之后调用(无论成功、失败或错误)。"""
self.logger.debug(f"Executing after_stage for stage '{self.id}', group '{api_group_name}'. Result status: {stage_result.overall_status.value}. Final context: {stage_context}")
pass
def before_step(self, step: StageStepDefinition, stage_context: Dict[str, Any], global_api_spec: ParsedAPISpec, api_group_name: Optional[str]):
"""在每个测试步骤执行之前调用。"""
self.logger.debug(f"Executing before_step for stage '{self.id}', step '{step.name}', group '{api_group_name}'. Current context: {stage_context}")
pass
def after_step(self, step: StageStepDefinition, step_result: ExecutedStageStepResult, stage_context: Dict[str, Any], global_api_spec: ParsedAPISpec, api_group_name: Optional[str]):
"""在每个测试步骤执行之后调用(无论成功、失败或错误)。"""
self.logger.debug(f"Executing after_step for stage '{self.id}', step '{step.name}', group '{api_group_name}'. Step status: {step_result.status.value}. Context after step: {stage_context}")
pass
def is_applicable_to_api_group(self, api_group_name: Optional[str], global_api_spec: ParsedAPISpec) -> bool:
"""
判断此测试阶段是否适用于给定的API分组。
默认情况下,如果阶段没有定义特定的适用性逻辑(例如,通过`tags`属性或覆盖此方法),
它将适用于所有API分组(如果 `api_group_name` 是 `None`,表示全局应用)或者
适用于名称与阶段 `tags` 中任何一个匹配的API分组。
子类可以覆盖此方法以实现更复杂的适用性判断逻辑。
"""
if not self.tags: # 如果阶段没有定义任何标签
self.logger.debug(f"Stage '{self.id}' has no specific tags defined. Applying to group '{api_group_name}' by default (true if group is None, or if specific logic isn't overridden to be more restrictive).")
return True # 默认适用于所有分组或全局
if api_group_name is None:
# 如果阶段有标签,但当前评估的是全局应用场景 (api_group_name is None)
# 那么它通常不应该在全局应用,除非设计者明确希望如此。
# 这里的行为可以调整:如果阶段有标签,它是否还应该应用于"全局"?
# 当前逻辑:如果阶段有标签,它只应用于匹配这些标签的分组。
self.logger.debug(f"Stage '{self.id}' has tags {self.tags}, but is being checked against global scope (api_group_name is None). Defaulting to not applicable to global if tags are present.")
return False
applicable = api_group_name in self.tags
self.logger.debug(f"Stage '{self.id}' (tags: {self.tags}) applicability to API group '{api_group_name}': {applicable}")
return applicable
def _match_api_endpoints_in_group(self):
"""
@@ -212,6 +435,10 @@ class BaseAPIStage:
if not match_found and hasattr(endpoint_obj, 'operation_id') and endpoint_obj.operation_id == lookup_key:
match_found = True
# 4. Check by YAPI _id (convert to string for safe comparison)
if not match_found and hasattr(endpoint_obj, '_id') and endpoint_obj._id is not None and str(endpoint_obj._id) == str(lookup_key):
match_found = True
if match_found:
self.logger.info(f"'{self.id}': 找到匹配操作 '{lookup_key}' -> Method: {endpoint_obj.method}, Path: {endpoint_obj.path}")
@@ -305,7 +532,7 @@ class ExecutedStageStepResult:
FAILED = "失败"
ERROR = "执行错误"
SKIPPED = "跳过"
PENDING = "处理中" # 新增:表示步骤正在等待或预处理
PENDING = "处理中"
def __init__(self,
step_name: str,
@@ -313,13 +540,13 @@ class ExecutedStageStepResult:
message: str = "",
validation_points: Optional[List[ValidationResult]] = None,
duration: float = 0.0,
api_call_detail: Optional[APICallDetail] = None, # 记录此步骤的API调用详情
api_call_detail: Optional[APICallDetail] = None,
extracted_outputs: Optional[Dict[str, Any]] = None,
description: Optional[str] = None, # <--- 添加 description
lookup_key: Optional[Union[str, Dict[str, str]]] = None, # <--- 添加 lookup_key
resolved_endpoint: Optional[str] = None, # <--- 添加 resolved_endpoint
request_details: Optional[Dict[str, Any]] = None, # <--- 添加 request_details
context_after_step: Optional[Dict[str, Any]] = None # <--- 添加 context_after_step
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
@@ -329,52 +556,77 @@ class ExecutedStageStepResult:
self.timestamp = time.time()
self.api_call_detail = api_call_detail
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 # <--- 设置属性
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 = []
if self.validation_points: # self.validation_points is List[Dict[str, Any]]
for vp_dict in self.validation_points: # vp_dict is a Dict from ValidationResult.to_dict()
# Access dictionary keys instead of object attributes
vp_details_content = vp_dict.get('details')
vp_passed = vp_dict.get('passed', False)
vp_message = vp_dict.get('message', '')
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_passed, "message": vp_message}
processed_detail = {"passed": vp_obj.passed, "message": vp_obj.message}
details_content = getattr(vp_obj, 'details', None)
if vp_details_content and isinstance(vp_details_content, dict):
if details_content and isinstance(details_content, dict):
try:
# 只取部分关键信息或确保可序列化
if "status_code" in vp_details_content:
processed_detail["status_code"] = vp_details_content["status_code"]
# 不直接序列化整个 response body 以免过大
# You might want to add other relevant serializable fields from vp_details_content
if "status_code" in details_content:
processed_detail["status_code_in_validation"] = details_content["status_code"]
# 可以添加其他从 details_content 中提取的可序列化字段
except TypeError:
# Update message if details were not serializable, though this part might be less reachable now
processed_detail["message"] = f"{vp_message} (Details not fully serializable)"
# 如果 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.append(processed_detail)
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)
return {
"step_name": self.step_name,
"description": self.description, # <--- 添加到输出
"lookup_key": self.lookup_key if isinstance(self.lookup_key, str) else str(self.lookup_key), # <--- 添加到输出 (确保字符串化)
"resolved_endpoint": self.resolved_endpoint, # <--- 添加到输出
"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": self.message or "; ".join([vp_dict.get('message', '') for vp_dict in self.validation_points if not vp_dict.get('passed')]),
"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,
"api_call_curl": self.api_call_detail.curl_command if self.api_call_detail else None,
"request_details": self.request_details, # <--- 添加到输出
"validation_points": vps_details_for_output,
"api_call_curl": getattr(getattr(self, 'api_call_detail', None), 'curl_command', 'N/A'),
"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)) else v) for k,v in (self.context_after_step or {}).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()
}
}
@@ -391,25 +643,29 @@ class ExecutedStageResult:
stage_id: str,
stage_name: str,
api_group_metadata: Optional[Dict[str, Any]] = None,
description: Optional[str] = None): # <--- 添加 description 参数
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.overall_status: ExecutedStageResult.Status = ExecutedStageResult.Status.PENDING
self.start_time: datetime = datetime.now()
self.end_time: Optional[datetime] = None
self.duration: float = 0.0
self.message: Optional[str] = None
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()
self.end_time = datetime.now() # Corrected
self.duration = (self.end_time - self.start_time).total_seconds()
self.final_context = final_context
@@ -452,6 +708,7 @@ class ExecutedStageResult:
"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'),
+69 -74
View File
@@ -6,6 +6,7 @@ import importlib.util
import inspect
import logging
from typing import List, Type, Dict, Optional
from pathlib import Path
from .stage_framework import BaseAPIStage # 导入新的 BaseAPIStage
@@ -13,111 +14,105 @@ logger = logging.getLogger(__name__)
class StageRegistry:
"""
负责发现、加载和管理 BaseAPIStage 子类
负责发现、加载和管理所有API测试阶段 (stages)
"""
def __init__(self, stages_dir: Optional[str] = None):
"""
初始化 StageRegistry。
Args:
stages_dir: 存放自定义 BaseAPIStage Python文件的目录路径。
如果为 None 或无效路径,则不会加载任何自定义阶段。
stages_dir: 存放自定义 APIStage Python文件的目录路径。
如果为 None,则不会加载任何阶段。
"""
self.logger = logging.getLogger(__name__)
self.stages_dir = stages_dir
self.stages_dir: Optional[Path] = Path(stages_dir) if stages_dir else None
self._stages: Dict[str, Type[BaseAPIStage]] = {}
self._errors: List[str] = []
if self.stages_dir and os.path.isdir(self.stages_dir):
self.logger.info(f"StageRegistry: 开始从目录 '{self.stages_dir}' 加载测试阶段...")
self._discover_and_load_stages()
if self._errors:
for error in self._errors:
self.logger.error(f"StageRegistry: 加载阶段时发生错误: {error}")
self.logger.info(f"StageRegistry: 加载完成。共加载 {len(self._stages)} 个测试阶段。")
elif stages_dir: # 如果提供了stages_dir但不是有效目录
self.logger.warning(f"StageRegistry: 提供的阶段目录 '{stages_dir}' 无效或不存在。将不会加载任何自定义阶段。")
else: # 如果 stages_dir 未提供
self.logger.info("StageRegistry: 未提供阶段目录,将不会加载任何自定义阶段。")
if self.stages_dir:
if not self.stages_dir.is_dir():
logger.warning(f"指定的阶段目录不存在或不是一个目录: {self.stages_dir}")
# Optionally, you could raise an error here or handle it as no stages found.
# For now, it will just result in no stages being loaded.
self.stages_dir = None # Prevent further processing if dir is invalid
else:
self.discover_stages()
else:
logger.info("没有提供阶段目录,将不加载任何自定义测试阶段。")
def _discover_and_load_stages(self):
"""发现并加载指定目录下的所有 BaseAPIStage 子类。"""
if not self.stages_dir or not os.path.isdir(self.stages_dir):
self.logger.warning(f"StageRegistry: 阶段目录 '{self.stages_dir}' 无效或不存在,无法发现阶段")
def discover_stages(self):
"""从指定的目录中发现并加载所有 BaseAPIStage 子类。"""
if not self.stages_dir or not self.stages_dir.is_dir():
logger.debug("阶段目录未设置或无效,跳过阶段发现")
return
self.logger.info(f"StageRegistry: 开始从目录 '{self.stages_dir}' 及其子目录发现测试阶段...")
found_count = 0
# 使用 os.walk 进行递归扫描
for root_dir, _, files in os.walk(self.stages_dir):
self._stages = {}
self._errors = []
logger.info(f"开始从目录发现测试阶段: {self.stages_dir}")
for root, _, files in os.walk(self.stages_dir):
for filename in files:
if filename.endswith(".py") and not filename.startswith("__"):
module_name = filename[:-3]
module_path = os.path.join(root_dir, filename) # 使用 root_dir
if filename.endswith('.py') and not filename.startswith('_'):
file_path = Path(root) / filename
module_name = f"ddms_compliance_suite.stages.{file_path.stem}" # 可以根据需要调整模块命名
try:
spec = importlib.util.spec_from_file_location(module_name, module_path)
spec = importlib.util.spec_from_file_location(module_name, file_path)
if spec and spec.loader:
module = importlib.util.module_from_spec(spec)
spec.loader.exec_module(module)
self.logger.debug(f"StageRegistry: 成功导入模块: {module_name}{module_path}")
logger.debug(f"成功加载模块: {module_name}{file_path}")
for name, cls in inspect.getmembers(module, inspect.isclass):
if issubclass(cls, BaseAPIStage) and cls is not BaseAPIStage:
if not hasattr(cls, 'id') or not cls.id: # 检查id属性是否存在且不为空
stage_id = cls.__name__
self.logger.warning(f"测试阶段类 '{cls.__name__}' (在模块 '{module_name}' 来自路径 '{module_path}') 未定义有效 'id' 属性,将使用类名 '{stage_id}' 作为其ID。建议为每个阶段设置唯一的 'id'")
else:
if hasattr(cls, 'id') and cls.id:
stage_id = cls.id
if stage_id in self._stages:
self.logger.warning(f"重复的测试阶段ID '{stage_id}' (来自类 '{cls.__name__}' 在模块 '{module_name}' 来自路径 '{module_path}')。之前的定义将被覆盖。请确保阶段ID唯一")
self._stages[stage_id] = cls
found_count +=1
self.logger.info(f"StageRegistry: 已注册测试阶段: '{stage_id}' (类: {cls.__name__}) 从模块 '{module_name}' (路径: {module_path})")
warning_msg = f"发现重复的阶段ID '{stage_id}' {file_path}。已加载的阶段来自 {self._stages[stage_id].__module__}。将被忽略"
logger.warning(warning_msg)
self._errors.append(warning_msg)
else:
self._errors.append(f"无法为文件 '{module_path}' 创建模块规范。")
self.logger.error(f"StageRegistry: 无法为文件 '{module_path}' 创建模块规范。")
self._stages[stage_id] = cls
logger.info(f"成功注册测试阶段: {stage_id} (来自 {module_name}.{name})")
else:
warning_msg = f"{file_path} 中发现的类 {name} 继承自 BaseAPIStage 但缺少 'id' 属性或ID为空。将被忽略。"
logger.warning(warning_msg)
self._errors.append(warning_msg)
else:
error_msg = f"无法为文件创建模块规范: {file_path}"
logger.error(error_msg)
self._errors.append(error_msg)
except ImportError as e:
error_msg = f"导入模块 '{module_name}' (从 '{module_path}') 失败: {e}"
error_msg = f"导入模块 {module_name}{file_path} 失败: {e}"
logger.error(error_msg, exc_info=True)
self._errors.append(error_msg)
self.logger.error(f"StageRegistry: {error_msg}", exc_info=True)
except Exception as e:
error_msg = f"加载或检查模块 '{module_name}' (从 '{module_path}') 时发生未知错误: {e}"
error_msg = f"加载或检查文件 {file_path} 时发生未知错误: {e}"
logger.error(error_msg, exc_info=True)
self._errors.append(error_msg)
self.logger.error(f"StageRegistry: {error_msg}", exc_info=True)
if found_count == 0 and not self._errors:
self.logger.info(f"StageRegistry: 在 '{self.stages_dir}' 及其子目录中未找到符合条件的测试阶段文件或类。")
elif self._errors:
self.logger.warning(f"StageRegistry: 测试阶段发现过程中遇到 {len(self._errors)} 个错误。请检查日志。")
logger.info(f"测试阶段发现完成。共加载 {len(self._stages)} 个阶段。发现 {len(self._errors)} 个错误。")
# 注意:StageRegistry 目前没有像 TestCaseRegistry 那样的排序逻辑,
# 如果需要按特定顺序执行 Stages (独立于 API group),未来可以添加。
def get_stage_class_by_id(self, stage_id: str) -> Optional[Type[BaseAPIStage]]:
"""根据ID获取已注册的测试阶段类。"""
return self._stages.get(stage_id)
def get_all_stage_classes(self) -> List[Type[BaseAPIStage]]:
"""获取所有已注册的测试阶段类的列表。"""
def get_all_stages(self) -> List[Type[BaseAPIStage]]:
"""返回所有已发现和加载的测试阶段类的列表。"""
return list(self._stages.values())
def get_load_errors(self) -> List[str]:
"""获取加载过程中发生的错误信息列表"""
def get_stage_by_id(self, stage_id: str) -> Optional[Type[BaseAPIStage]]:
"""根据ID获取特定的测试阶段类"""
return self._stages.get(stage_id)
def get_discovery_errors(self) -> List[str]:
"""返回在发现过程中遇到的任何错误信息。"""
return self._errors
def clear_stages(self):
"""清空所有已加载的阶段和错误信息。"""
self._stages = {}
self._errors = []
logger.debug("StageRegistry 已清空。")
def reload_stages(self):
"""
重新加载所有测试阶段。会清空当前已加载的阶段和错误记录。
"""
self.logger.info(f"StageRegistry: 正在从目录 '{self.stages_dir}' 重新加载所有测试阶段...")
self._stages.clear()
self._errors.clear()
if self.stages_dir and os.path.isdir(self.stages_dir):
self._discover_and_load_stages()
if self._errors:
for error in self._errors:
self.logger.error(f"StageRegistry (重载时): 加载阶段时发生错误: {error}")
self.logger.info(f"StageRegistry: 重新加载完成。共加载 {len(self._stages)} 个测试阶段。")
elif self.stages_dir:
self.logger.warning(f"StageRegistry (重载时): 提供的阶段目录 '{self.stages_dir}' 无效或不存在。没有加载任何自定义阶段。")
"""清空并重新从目录加载所有阶段。"""
self.clear_stages()
if self.stages_dir:
self.discover_stages()
else:
self.logger.info("StageRegistry (重载时): 未配置阶段目录,没有加载任何自定义阶段。")
logger.info("没有配置阶段目录,无法重新加载阶段。")
File diff suppressed because it is too large Load Diff
+2597 -2578
View File
File diff suppressed because one or more lines are too long
File diff suppressed because it is too large Load Diff
+261 -394
View File
File diff suppressed because it is too large Load Diff