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

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的实际响应结构。
def find_and_extract_id_by_name(response_ctx: APIResponseContext, stage_ctx: dict) -> ValidationResult:
"""
(通用版) 在列表响应中通过唯一名称查找新创建的资源
验证其存在并将其唯一标识符例如 'id', 'dsid', 'uuid'提取到阶段上下文中
# --- 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
需要用户在 '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
current = data
try:
for part in path_str.split('.'):
if isinstance(current, dict) and part in current:
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]
elif isinstance(current, list) and part.isdigit() and 0 <= int(part) < len(current):
current = current[int(part)]
else:
return None
return current
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})."))
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}'."))
return ValidationResult(passed=True, message=f"资源详情 (ID: {created_id}) 校验成功。")
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)}'")
return ValidationResult(passed=True, message=f"更新后资源详情 (ID: {created_id}) 校验成功,名称为 '{updated_name}'")
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}'."))
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
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)
# 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:
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_to_return = title_to_check # Default to title (YAPI primary)
lookup_key = getattr(ep_obj, 'title', None) or \
getattr(ep_obj, 'operation_id', None) or \
f"{ep_method_actual} {ep_path}"
text_lower = text_for_matching.lower()
resource_kw_lower = resource_kw.lower()
text_lower = text_to_match.lower()
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
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}")
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}'.")
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})")
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
# These are placeholders and likely need to be adapted or fetched from config/context
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
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
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}}"
# --- 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}",
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}",
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}}",
**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}}"}, # Example
"query_params": {"id": "{{stage_context.created_resource_id}}"}, # Example if ID is in query for PUT
"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": { # EXAMPLE BODY - MODIFY FOR YOUR API
"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
}
"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
"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": { # EXAMPLE BODY for delete (if it takes a body) - MODIFY FOR YOUR API
"version": "{{stage_context.api_version}}",
"data": ["{{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
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())}")

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)

View File

@ -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) # 返回副本

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.")
# 对步骤进行排序
self.steps = sorted(self.steps, key=lambda s: s.order)
if self.steps: # Ensure steps exist before trying to sort
self.steps = sorted(self.steps, key=lambda s: s.order)
else:
self.steps = []
def get_api_spec_for_operation(self,
lookup_key: str,
global_api_spec: ParsedAPISpec, # 确保类型正确
api_group_name: Optional[str] = None
) -> Optional[APIOperationSpec]:
"""
根据查找键从提供的API规范中获取特定API操作的详细信息
这个方法需要子类根据其查找逻辑来实现或者提供一个通用的基于operationId或path+method的查找
def is_applicable_to_api_group(self, api_group_metadata: Dict[str, Any], apis_in_group: List[Dict[str, Any]]) -> bool:
Args:
lookup_key: 用于查找API操作的键 (例如 operationId, "METHOD /path", 或自定义键)
global_api_spec: 完整的已解析API规范对象
api_group_name: (可选) 当前API分组的名称用于更精确的查找或作用域限定
Returns:
APIOperationSpec 对象如果找到否则 None
"""
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分组
子类可以重写此方法以实现更复杂的适用性逻辑
Args:
api_group_metadata: API分组的元数据 (例如 YAPI category name/id Swagger tag name/description)
apis_in_group: 该分组内的API端点定义列表
Returns:
True 如果此阶段适用于该API分组否则 False
默认情况下如果阶段没有定义特定的适用性逻辑例如通过`tags`属性或覆盖此方法
它将适用于所有API分组如果 `api_group_name` `None`表示全局应用或者
适用于名称与阶段 `tags` 中任何一个匹配的API分组
子类可以覆盖此方法以实现更复杂的适用性判断逻辑
"""
return True # 默认应用于所有分组
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,14 +540,14 @@ 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
self.message = message
@ -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', '')
processed_detail = {"passed": vp_passed, "message": vp_message}
if vp_details_content and isinstance(vp_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
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)"
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
vps_details.append(processed_detail)
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)
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, # <--- 添加到输出
"extracted_outputs": {k: str(v)[:200] + '...' if isinstance(v, (str, bytes)) and len(v) > 200 else v
"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'),

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})")
if stage_id in self._stages:
warning_msg = f"发现重复的阶段ID '{stage_id}'{file_path}。已加载的阶段来自 {self._stages[stage_id].__module__}。将被忽略。"
logger.warning(warning_msg)
self._errors.append(warning_msg)
else:
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:
self._errors.append(f"无法为文件 '{module_path}' 创建模块规范。")
self.logger.error(f"StageRegistry: 无法为文件 '{module_path}' 创建模块规范。")
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)} 个错误。请检查日志。")
# 注意StageRegistry 目前没有像 TestCaseRegistry 那样的排序逻辑,
# 如果需要按特定顺序执行 Stages (独立于 API group),未来可以添加。
logger.info(f"测试阶段发现完成。共加载 {len(self._stages)} 个阶段。发现 {len(self._errors)} 个错误。")
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

File diff suppressed because one or more lines are too long

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff