fix:stage_group
This commit is contained in:
Binary file not shown.
@@ -2,431 +2,504 @@ import time
|
|||||||
import uuid
|
import uuid
|
||||||
import logging
|
import logging
|
||||||
import re
|
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.stage_framework import BaseAPIStage, StageStepDefinition, APIOperationSpec, ExecutedStageResult, ExecutedStageStepResult
|
||||||
from ddms_compliance_suite.test_framework_core import ValidationResult, APIResponseContext
|
from ddms_compliance_suite.test_framework_core import ValidationResult, APIResponseContext
|
||||||
from ddms_compliance_suite.input_parser.parser import ParsedAPISpec
|
from ddms_compliance_suite.input_parser.parser import ParsedAPISpec, YAPIEndpoint, SwaggerEndpoint
|
||||||
|
|
||||||
# =====================================================================================
|
|
||||||
# USER CONFIGURATION: RESOURCE KEYWORD
|
|
||||||
# =====================================================================================
|
|
||||||
# 请用户根据实际要测试的资源修改此关键字,例如 "用户", "订单", "测井曲线" 等。
|
|
||||||
# 这个关键字将用于在API规范的端点标题中模糊查找相关的操作。
|
|
||||||
RESOURCE_KEYWORD = "地质单元" # <-- 【【【请修改这里!】】】
|
|
||||||
# =====================================================================================
|
|
||||||
|
|
||||||
# --- Action Keywords for Discovery (Usually these can remain as is) ---
|
# --- Action Keywords for Discovery (Usually these can remain as is) ---
|
||||||
CREATE_ACTION_KEYWORDS = ["添加", "新建", "创建"]
|
CREATE_ACTION_KEYWORDS = ["add", "create", "new", "添加", "新建", "创建"]
|
||||||
LIST_ACTION_KEYWORDS = ["列表查询", "查询列表", "获取列表", "分页查询"]
|
LIST_ACTION_KEYWORDS = ["list", "query", "search", "getall", "getlist", "列表查询", "查询列表", "获取列表", "分页查询"]
|
||||||
DETAIL_ACTION_KEYWORDS = ["查询详情", "获取详情", "根据ID获取"]
|
DETAIL_ACTION_KEYWORDS = ["detail", "getbyid", "getone", "查询详情", "获取详情", "根据ID获取"]
|
||||||
UPDATE_ACTION_KEYWORDS = ["修改", "更新"]
|
UPDATE_ACTION_KEYWORDS = ["update", "edit", "put", "修改", "更新"]
|
||||||
DELETE_ACTION_KEYWORDS = ["删除"]
|
DELETE_ACTION_KEYWORDS = ["delete", "remove", "删除"]
|
||||||
|
|
||||||
# --- Default values that might come from config or context in a real scenario ---
|
# --- Default values that might come from config or context ---
|
||||||
# --- 这些值也可能需要根据您的API进行调整 ---
|
DEFAULT_DMS_INSTANCE_CODE_PLACEHOLDER = "your_dms_instance_code"
|
||||||
DEFAULT_DMS_INSTANCE_CODE_PLACEHOLDER = "your_dms_instance_code" # 示例占位符
|
DEFAULT_API_VERSION_PLACEHOLDER = "1.0.0"
|
||||||
DEFAULT_API_VERSION_PLACEHOLDER = "1.0.0" # 示例占位符
|
DEFAULT_TENANT_ID_PLACEHOLDER = "your-tenant-id"
|
||||||
DEFAULT_TENANT_ID_PLACEHOLDER = "your-tenant-id" # 示例占位符
|
DEFAULT_AUTHORIZATION_PLACEHOLDER = "Bearer your-test-token"
|
||||||
DEFAULT_AUTHORIZATION_PLACEHOLDER = "Bearer your-test-token" # 示例占位符
|
|
||||||
|
|
||||||
|
|
||||||
# --- Custom Assertion Helper Functions & Context Manipulators ---
|
# --- Helper to navigate JSON path ---
|
||||||
# 【【【重要提示】】】: 下面的这些辅助函数中的JSON路径 (如 'data.list', 'wellCommonName', 'dsid')
|
def _get_value_by_path(data: Optional[Dict[str, Any]], path_str: Optional[str]):
|
||||||
# 是基于 '井筒API示例_simple.json' 的结构。
|
if data is None or path_str is None or not path_str:
|
||||||
# 当您修改 RESOURCE_KEYWORD 并适配您自己的API时,您【必须】检查并修改这些路径以匹配您API的实际响应结构。
|
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
|
current = data
|
||||||
|
try:
|
||||||
for part in path_str.split('.'):
|
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)
|
||||||
current = current[part]
|
if match_list_index:
|
||||||
elif isinstance(current, list) and part.isdigit() and 0 <= int(part) < len(current):
|
list_name, index_str = match_list_index.groups()
|
||||||
current = current[int(part)]
|
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:
|
else:
|
||||||
return None
|
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
|
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):
|
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
|
found_item = None
|
||||||
for item in resource_list:
|
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
|
found_item = item
|
||||||
break
|
break
|
||||||
|
|
||||||
if not found_item:
|
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)
|
resource_id = found_item.get(id_field)
|
||||||
if not resource_id:
|
if resource_id is None:
|
||||||
return ValidationResult(passed=False, message=f"找到名称为 '{unique_name_to_find}' 的资源,但它缺少ID字段 '{ID_FIELD_IN_LIST_ITEM}'。")
|
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
|
stage_ctx["created_resource_id"] = resource_id
|
||||||
logger.info(f"成功在列表中找到资源 '{unique_name_to_find}' 并提取其ID ({ID_FIELD_IN_LIST_ITEM}): {resource_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"找到资源 '{unique_name_to_find}' 并提取ID: {resource_id}。建议使用 outputs_to_context 获取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:
|
def check_resource_details(response_ctx: APIResponseContext, stage_ctx: dict) -> ValidationResult:
|
||||||
"""
|
resource_object_path = stage_ctx.get("cfg_resource_object_path_in_detailresponse", "")
|
||||||
(通用版) 验证获取到的资源详情。
|
name_field = stage_ctx.get("cfg_name_field_in_detailresponse", "name")
|
||||||
假设 'created_resource_id' 和 'unique_resource_name' 在 stage_ctx 中。
|
id_field = stage_ctx.get("cfg_id_field_in_detailresponse", "id")
|
||||||
假设响应直接就是资源对象,或者在 'data.list[0]' 或 'data' 中 (需要用户调整)。
|
feature_field_name = stage_ctx.get("cfg_feature_field_name_for_validation")
|
||||||
"""
|
expected_feature_value = stage_ctx.get("unique_feature_value")
|
||||||
# 【【【请用户根据实际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"
|
|
||||||
# 【【【修改结束】】】
|
|
||||||
|
|
||||||
created_id = stage_ctx.get("created_resource_id")
|
created_id = stage_ctx.get("created_resource_id")
|
||||||
initial_name = stage_ctx.get("unique_resource_name")
|
initial_name = stage_ctx.get("unique_resource_name")
|
||||||
|
|
||||||
response_data = response_ctx.json_content
|
resource_data = _get_value_by_path(response_ctx.json_content, resource_object_path)
|
||||||
|
|
||||||
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)
|
|
||||||
|
|
||||||
if not isinstance(resource_data, dict):
|
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:
|
validation_results = []
|
||||||
return ValidationResult(passed=False, message=f"ID不匹配。期望 {created_id}, 得到 {resource_data.get(ID_FIELD_IN_DETAIL)} (字段: {ID_FIELD_IN_DETAIL})。")
|
if resource_data.get(id_field) != created_id:
|
||||||
if NAME_FIELD_IN_DETAIL and resource_data.get(NAME_FIELD_IN_DETAIL) != initial_name: # Name check is optional
|
validation_results.append(ValidationResult(passed=False, message=f"ID mismatch. Expected '{created_id}', got '{resource_data.get(id_field)}' (field: {id_field})."))
|
||||||
return ValidationResult(passed=False, message=f"名称不匹配。期望 '{initial_name}', 得到 '{resource_data.get(NAME_FIELD_IN_DETAIL)}' (字段: {NAME_FIELD_IN_DETAIL})。")
|
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:
|
def check_resource_updated_details(response_ctx: APIResponseContext, stage_ctx: dict) -> ValidationResult:
|
||||||
"""
|
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")
|
||||||
假设 'created_resource_id' 和 'updated_resource_name' 在 stage_ctx 中。
|
id_field = stage_ctx.get("cfg_id_field_in_detailresponse", "id")
|
||||||
"""
|
feature_field_name = stage_ctx.get("cfg_feature_field_name_for_validation")
|
||||||
# 【【【请用户根据实际API修改下面的JSON路径和字段名】】】
|
expected_updated_feature_value = stage_ctx.get("updated_feature_value")
|
||||||
RESOURCE_OBJECT_PATH_IN_RESPONSE = "data.list[0]"
|
|
||||||
NAME_FIELD_IN_DETAIL = "wellCommonName"
|
|
||||||
ID_FIELD_IN_DETAIL = "dsid"
|
|
||||||
# 【【【修改结束】】】
|
|
||||||
|
|
||||||
created_id = stage_ctx.get("created_resource_id")
|
created_id = stage_ctx.get("created_resource_id")
|
||||||
updated_name = stage_ctx.get("updated_resource_name")
|
updated_name = stage_ctx.get("updated_resource_name")
|
||||||
|
|
||||||
# (Code is very similar to check_resource_details, could be refactored if desired)
|
resource_data = _get_value_by_path(response_ctx.json_content, resource_object_path)
|
||||||
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)
|
|
||||||
|
|
||||||
if not isinstance(resource_data, dict):
|
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:
|
validation_results = []
|
||||||
return ValidationResult(passed=False, message=f"更新后ID不匹配。期望 {created_id}, 得到 {resource_data.get(ID_FIELD_IN_DETAIL)}。")
|
if resource_data.get(id_field) != created_id:
|
||||||
if NAME_FIELD_IN_DETAIL and resource_data.get(NAME_FIELD_IN_DETAIL) != updated_name:
|
validation_results.append(ValidationResult(passed=False, message=f"Updated ID mismatch. Expected '{created_id}', got '{resource_data.get(id_field)}'."))
|
||||||
return ValidationResult(passed=False, message=f"更新后名称不匹配。期望 '{updated_name}', 得到 '{resource_data.get(NAME_FIELD_IN_DETAIL)}'。")
|
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):
|
class GenericCRUDValidationStage(BaseAPIStage):
|
||||||
id = "keyword_driven_crud_example"
|
id = "generic_crud_validation_stage"
|
||||||
name = "Keyword-Driven Generic CRUD Stage Example"
|
name = "Generic CRUD Validation Stage"
|
||||||
description = (
|
description = (
|
||||||
"Demonstrates a CRUD (Create, List, Read, Update, Delete) flow. "
|
"Performs a generic CRUD (Create, List, Read, Update, Delete) flow. "
|
||||||
"This stage dynamically finds API operations based on a configurable RESOURCE_KEYWORD "
|
"It tries to dynamically discover API operations based on common keywords and HTTP methods. "
|
||||||
"and predefined action keywords (e.g., '添加', '查询列表'). "
|
"Field names for IDs, names, features, and JSON paths for list/detail objects are configurable "
|
||||||
"IMPORTANT: User MUST configure RESOURCE_KEYWORD at the top of this file. "
|
"via class attributes or can be set in `before_stage` based on `api_group_name`."
|
||||||
"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."
|
|
||||||
)
|
)
|
||||||
tags = ["crud", "keyword_driven", "example"]
|
tags = ["crud", "generic_validation"]
|
||||||
continue_on_failure = False # Set to True if you want to attempt all steps even if one fails
|
continue_on_failure = False
|
||||||
|
fail_if_not_applicable_to_any_group = False
|
||||||
|
|
||||||
# This will be populated by is_applicable_to_api_group
|
# --- Configurable field names and JSON paths ---
|
||||||
discovered_op_keys: dict = {}
|
cfg_id_field_in_create_response: str = "data.0.dsid"
|
||||||
# This will be populated in before_stage
|
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 = []
|
steps: list = []
|
||||||
|
|
||||||
def _find_operation_title_or_id(self, endpoints: list, resource_kw: str, action_kws: list, http_method: str | None = None) -> str | None:
|
def _determine_resource_config(self, api_group_name: Optional[str], global_api_spec: ParsedAPISpec):
|
||||||
"""Tries to find an endpoint by matching keywords in its title and optionally method."""
|
self.logger.info(f"Using default field/path configurations for group '{api_group_name}'. Customize by overriding _determine_resource_config or cfg_* attributes.")
|
||||||
self.logger.debug(f"Searching for op with resource='{resource_kw}', actions='{action_kws}', method='{http_method}'")
|
pass
|
||||||
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)
|
|
||||||
|
|
||||||
# Determine primary text for keyword matching
|
|
||||||
text_for_matching = title_to_check or summary_to_check or op_id_to_check
|
def _find_operation_best_match(self, endpoints: List[Union[YAPIEndpoint, SwaggerEndpoint]],
|
||||||
if not text_for_matching:
|
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
|
continue
|
||||||
|
|
||||||
# The key we will use to lookup the endpoint later in the orchestrator
|
lookup_key = getattr(ep_obj, 'title', None) or \
|
||||||
# The orchestrator uses 'title' for YAPI and 'operationId' or (method+path) for Swagger
|
getattr(ep_obj, 'operation_id', None) or \
|
||||||
# For simplicity in this dynamic stage, we will try to return YAPI's 'title' if available and it matches,
|
f"{ep_method_actual} {ep_path}"
|
||||||
# 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)
|
text_lower = text_to_match.lower()
|
||||||
|
|
||||||
text_lower = text_for_matching.lower()
|
action_match = any(action_kw.lower() in text_lower for action_kw in action_kws)
|
||||||
resource_kw_lower = resource_kw.lower()
|
self.logger.debug(f" For '{ep_title}': Action match with '{action_kws}'? {action_match}")
|
||||||
|
|
||||||
if resource_kw_lower in text_lower:
|
method_match = (not target_method) or (ep_method_actual == target_method.upper())
|
||||||
if any(action_kw.lower() in text_lower for action_kw in action_kws):
|
self.logger.debug(f" For '{ep_title}': Method match with '{target_method}'? {method_match} (Endpoint method: {ep_method_actual})")
|
||||||
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
|
|
||||||
|
|
||||||
def is_applicable_to_api_group(self, api_group_name: str | None, global_api_spec: ParsedAPISpec) -> bool:
|
path_contains_id_param = False
|
||||||
self.logger.info(f"Checking applicability of '{self.name}' (RESOURCE_KEYWORD: '{RESOURCE_KEYWORD}') for API group '{api_group_name}'.")
|
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:
|
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.")
|
self.logger.warning(f"'{self.name}' cannot determine applicability: global_api_spec or its endpoints are missing.")
|
||||||
return False
|
return False
|
||||||
|
|
||||||
# Reset for this applicability check
|
self._determine_resource_config(api_group_name, global_api_spec)
|
||||||
|
|
||||||
self.discovered_op_keys = {}
|
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["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_title_or_id(all_endpoints, RESOURCE_KEYWORD, LIST_ACTION_KEYWORDS, "POST") # Assuming list is POST for this example
|
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_title_or_id(all_endpoints, RESOURCE_KEYWORD, DETAIL_ACTION_KEYWORDS, "GET")
|
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_title_or_id(all_endpoints, RESOURCE_KEYWORD, UPDATE_ACTION_KEYWORDS, "PUT")
|
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_title_or_id(all_endpoints, RESOURCE_KEYWORD, DELETE_ACTION_KEYWORDS, "DELETE")
|
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:
|
if not missing_required_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}")
|
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
|
return True
|
||||||
else:
|
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
|
return False
|
||||||
|
|
||||||
def before_stage(self, stage_context: dict, global_api_spec: ParsedAPISpec, api_group_name: str | None):
|
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}")
|
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_timestamp = int(time.time())
|
||||||
run_uuid_short = uuid.uuid4().hex[:6]
|
run_uuid_short = uuid.uuid4().hex[:6]
|
||||||
|
|
||||||
# Generate unique names for this run, using the configured RESOURCE_KEYWORD
|
unique_name_for_run = f"TestResource_{run_timestamp}_{run_uuid_short}"
|
||||||
# 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}"
|
|
||||||
updated_name_for_run = f"{unique_name_for_run}_UPDATED"
|
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["unique_resource_name"] = unique_name_for_run
|
||||||
stage_context["updated_resource_name"] = updated_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["dms_instance_code"] = DEFAULT_DMS_INSTANCE_CODE_PLACEHOLDER
|
||||||
stage_context["api_version"] = DEFAULT_API_VERSION_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()):
|
if not self.discovered_op_keys.get("create") or \
|
||||||
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.")
|
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 = []
|
self.steps = []
|
||||||
return
|
return
|
||||||
|
|
||||||
# --- Dynamically build steps using discovered keys ---
|
create_body_data = {
|
||||||
# 【【【重要提示】】】: 下面的 request_overrides (尤其是 'body') 和 'outputs_to_context'
|
self.cfg_name_field_in_create_payload: "{{stage_context.unique_resource_name}}",
|
||||||
# 是基于 '井筒API示例_simple.json' (当RESOURCE_KEYWORD="地质单元"时)的结构。
|
}
|
||||||
# 当您修改 RESOURCE_KEYWORD 并用于您自己的API时,
|
if self.cfg_feature_field_in_create_payload:
|
||||||
# 您【必须】相应地修改这些 'body' 结构和 'outputs_to_context' 中的JSON路径。
|
create_body_data[self.cfg_feature_field_in_create_payload] = "{{stage_context.unique_feature_value}}"
|
||||||
self.steps = [
|
|
||||||
StageStepDefinition(
|
final_create_body = {
|
||||||
name=f"Add New {RESOURCE_KEYWORD}",
|
"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"],
|
endpoint_spec_lookup_key=self.discovered_op_keys["create"],
|
||||||
request_overrides={
|
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},
|
"headers": {"tenant-id": DEFAULT_TENANT_ID_PLACEHOLDER, "Authorization": DEFAULT_AUTHORIZATION_PLACEHOLDER},
|
||||||
"body": { # EXAMPLE BODY - MODIFY FOR YOUR API
|
"body": final_create_body
|
||||||
"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
|
|
||||||
}]
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
expected_status_codes=[200], # Modify as per your API
|
expected_status_codes=[200, 201],
|
||||||
# outputs_to_context: {"created_resource_id": "body.data[0].dsid"} # MODIFY FOR YOUR API
|
outputs_to_context={"created_resource_id": self.cfg_id_field_in_create_response}
|
||||||
),
|
))
|
||||||
StageStepDefinition(
|
|
||||||
name=f"List and Find Created {RESOURCE_KEYWORD}",
|
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"],
|
endpoint_spec_lookup_key=self.discovered_op_keys["list"],
|
||||||
request_overrides={
|
request_overrides={
|
||||||
"path_params": { # Example path params
|
"path_params": {
|
||||||
"dms_instance_code": "{{stage_context.dms_instance_code}}",
|
"dms_instance_code": "{{stage_context.dms_instance_code}}",
|
||||||
"version": "{{stage_context.api_version}}"
|
"version": "{{stage_context.api_version}}"
|
||||||
},
|
},
|
||||||
"headers": {"tenant-id": DEFAULT_TENANT_ID_PLACEHOLDER, "Authorization": DEFAULT_AUTHORIZATION_PLACEHOLDER},
|
"headers": {"tenant-id": DEFAULT_TENANT_ID_PLACEHOLDER, "Authorization": DEFAULT_AUTHORIZATION_PLACEHOLDER},
|
||||||
"query_params": {"pageNo": 1, "pageSize": 10}, # Example query params
|
"query_params": {"pageNo": 1, "pageSize": 10},
|
||||||
"body": { # EXAMPLE BODY for a POST list query - MODIFY FOR YOUR API
|
"body": {
|
||||||
"isSearchCount": True,
|
"isSearchCount": True,
|
||||||
"query": {
|
"query": {
|
||||||
"fields": ["dsid", "wellCommonName"], # Example fields - MODIFY
|
"fields": [self.cfg_id_field_in_list_item, self.cfg_name_field_in_list_item, self.cfg_feature_field_in_list_item],
|
||||||
"filter": { # Example filter - MODIFY
|
"filter": list_query_filter
|
||||||
"key": "wellCommonName",
|
|
||||||
"symbol": "=",
|
|
||||||
"realValue": ["{{stage_context.unique_resource_name}}"]
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
expected_status_codes=[200],
|
expected_status_codes=[200],
|
||||||
response_assertions=[find_and_extract_id_by_name], # Uses generic helper, ensure its internal paths are also updated!
|
response_assertions=[find_resource_in_list_and_extract_id],
|
||||||
outputs_to_context={"created_resource_id": "body.data.list[0].dsid"} # EXAMPLE output - MODIFY
|
outputs_to_context={"found_id_from_list": f"{self.cfg_list_path_in_list_response}.0.{self.cfg_id_field_in_list_item}"}
|
||||||
),
|
))
|
||||||
StageStepDefinition(
|
|
||||||
name=f"Get Created {RESOURCE_KEYWORD} Details",
|
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"],
|
endpoint_spec_lookup_key=self.discovered_op_keys["detail"],
|
||||||
request_overrides={
|
request_overrides={
|
||||||
"path_params": { # Example path params - MODIFY
|
"path_params": {
|
||||||
"dms_instance_code": "{{stage_context.dms_instance_code}}",
|
"dms_instance_code": "{{stage_context.dms_instance_code}}",
|
||||||
"version": "{{stage_context.api_version}}",
|
"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}
|
"headers": {"tenant-id": DEFAULT_TENANT_ID_PLACEHOLDER, "Authorization": DEFAULT_AUTHORIZATION_PLACEHOLDER}
|
||||||
},
|
},
|
||||||
expected_status_codes=[200],
|
expected_status_codes=[200],
|
||||||
response_assertions=[check_resource_details] # Uses generic helper, ensure its internal paths are also updated!
|
response_assertions=[check_resource_details]
|
||||||
),
|
))
|
||||||
StageStepDefinition(
|
|
||||||
name=f"Update Created {RESOURCE_KEYWORD}",
|
if self.discovered_op_keys["update"] and self.discovered_op_keys["create"]:
|
||||||
endpoint_spec_lookup_key=self.discovered_op_keys["update"],
|
update_body_data = {
|
||||||
request_overrides={
|
self.cfg_id_field_in_detail_response: "{{stage_context.created_resource_id}}",
|
||||||
"path_params": {"dms_instance_code": "{{stage_context.dms_instance_code}}"}, # Example
|
self.cfg_name_field_in_detail_response: "{{stage_context.updated_resource_name}}",
|
||||||
"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},
|
if self.cfg_feature_field_in_create_payload:
|
||||||
"body": { # EXAMPLE BODY - MODIFY FOR YOUR API
|
update_body_data[self.cfg_feature_field_in_create_payload] = "{{stage_context.updated_feature_value}}"
|
||||||
|
|
||||||
|
final_update_body = {
|
||||||
"id": "{{stage_context.created_resource_id}}",
|
"id": "{{stage_context.created_resource_id}}",
|
||||||
"version": "{{stage_context.api_version}}",
|
"version": "{{stage_context.api_version}}",
|
||||||
"wellCommonName": "{{stage_context.updated_resource_name}}", # Example field
|
**update_body_data
|
||||||
"dataRegion": "TEST_REGION_UPDATED" # Example field
|
|
||||||
# Add other required fields for your API's update operation
|
|
||||||
}
|
}
|
||||||
|
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],
|
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"],
|
endpoint_spec_lookup_key=self.discovered_op_keys["detail"],
|
||||||
request_overrides={
|
request_overrides={
|
||||||
"path_params": {
|
"path_params": {
|
||||||
"dms_instance_code": "{{stage_context.dms_instance_code}}",
|
"dms_instance_code": "{{stage_context.dms_instance_code}}",
|
||||||
"version": "{{stage_context.api_version}}",
|
"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}
|
"headers": {"tenant-id": DEFAULT_TENANT_ID_PLACEHOLDER, "Authorization": DEFAULT_AUTHORIZATION_PLACEHOLDER}
|
||||||
},
|
},
|
||||||
expected_status_codes=[200],
|
expected_status_codes=[200],
|
||||||
response_assertions=[check_resource_updated_details] # Uses generic helper, check paths!
|
response_assertions=[check_resource_updated_details]
|
||||||
),
|
))
|
||||||
StageStepDefinition(
|
|
||||||
name=f"Delete Created {RESOURCE_KEYWORD}",
|
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"],
|
endpoint_spec_lookup_key=self.discovered_op_keys["delete"],
|
||||||
request_overrides={
|
request_overrides={
|
||||||
"path_params": {"dms_instance_code": "{{stage_context.dms_instance_code}}"}, # Example
|
"path_params": {
|
||||||
"query_params": {"id": "{{stage_context.created_resource_id}}"}, # Example if ID is in query for DELETE
|
"dms_instance_code": "{{stage_context.dms_instance_code}}",
|
||||||
"headers": {"tenant-id": DEFAULT_TENANT_ID_PLACEHOLDER, "Authorization": DEFAULT_AUTHORIZATION_PLACEHOLDER},
|
self.cfg_path_param_name_for_id: "{{stage_context.created_resource_id}}"
|
||||||
"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.
|
"headers": {"tenant-id": DEFAULT_TENANT_ID_PLACEHOLDER, "Authorization": DEFAULT_AUTHORIZATION_PLACEHOLDER},
|
||||||
),
|
},
|
||||||
StageStepDefinition(
|
expected_status_codes=[200, 204],
|
||||||
name=f"Verify {RESOURCE_KEYWORD} Deletion",
|
))
|
||||||
endpoint_spec_lookup_key=self.discovered_op_keys["detail"], # Try to get it again
|
|
||||||
|
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={
|
request_overrides={
|
||||||
"path_params": {
|
"path_params": {
|
||||||
"dms_instance_code": "{{stage_context.dms_instance_code}}",
|
"dms_instance_code": "{{stage_context.dms_instance_code}}",
|
||||||
"version": "{{stage_context.api_version}}",
|
"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}
|
"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):
|
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}")
|
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())}")
|
||||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -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) # 返回副本
|
|
||||||
@@ -6,6 +6,7 @@ import time
|
|||||||
from typing import List, Dict, Any, Callable, Optional, Union
|
from typing import List, Dict, Any, Callable, Optional, Union
|
||||||
from enum import Enum
|
from enum import Enum
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
|
||||||
# Add Pydantic BaseModel for APIOperationSpec
|
# Add Pydantic BaseModel for APIOperationSpec
|
||||||
from pydantic import BaseModel
|
from pydantic import BaseModel
|
||||||
@@ -25,14 +26,145 @@ except ImportError:
|
|||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
# 定义 APIOperationSpec
|
# Forward declaration for type hinting if ParsedAPISpec is used directly
|
||||||
class APIOperationSpec(BaseModel):
|
ParsedAPISpec = "ParsedAPISpec"
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class APIOperationSpec:
|
||||||
|
"""封装了从全局API规范中解析出来的特定API操作的详细信息"""
|
||||||
method: str
|
method: str
|
||||||
path: str
|
path: str
|
||||||
spec: Dict[str, Any] # 原始API端点定义字典
|
spec: Dict[str, Any] # 该API操作的完整OpenAPI/Swagger规范字典部分
|
||||||
operation_id: Optional[str] = None
|
operation_id: Optional[str] = None
|
||||||
summary: Optional[str] = None
|
summary: Optional[str] = None
|
||||||
description: 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标题中的关键字列表
|
# 键是标准化的操作类型,值是可能出现在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:
|
class BaseAPIStage:
|
||||||
"""
|
"""
|
||||||
API测试阶段的基类。
|
API测试阶段的基类。
|
||||||
@@ -84,22 +191,33 @@ class BaseAPIStage:
|
|||||||
# 由子类定义,表示此Stage中的API调用步骤
|
# 由子类定义,表示此Stage中的API调用步骤
|
||||||
steps: List[StageStepDefinition] = []
|
steps: List[StageStepDefinition] = []
|
||||||
|
|
||||||
|
# 新增: 控制当此阶段不适用于任何API分组时,是否标记为失败
|
||||||
|
fail_if_not_applicable_to_any_group: bool = False
|
||||||
|
|
||||||
|
# 新增: 控制当一个步骤失败/错误时,是否继续执行后续步骤
|
||||||
|
continue_on_failure: bool = False
|
||||||
|
|
||||||
def __init__(self,
|
def __init__(self,
|
||||||
api_group_metadata: Dict[str, Any],
|
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,
|
llm_service: Optional[LLMService] = None,
|
||||||
global_api_spec: Optional[ParsedAPISpec] = None, # <--- 修改类型注解
|
global_api_spec: Optional[ParsedAPISpec] = None, # <--- 修改类型注解
|
||||||
operation_keywords: Optional[Dict[str, List[str]]] = None):
|
operation_keywords: Optional[Dict[str, List[str]]] = None):
|
||||||
self.logger = logging.getLogger(f"{__name__}.{self.__class__.__name__}")
|
self.logger = logging.getLogger(f"{__name__}.{self.__class__.__name__}")
|
||||||
self.current_api_group_metadata = api_group_metadata
|
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.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._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
|
# 确保子类定义了ID
|
||||||
if not self.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.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)
|
self.steps = sorted(self.steps, key=lambda s: s.order)
|
||||||
|
else:
|
||||||
|
self.steps = []
|
||||||
|
|
||||||
|
def get_api_spec_for_operation(self,
|
||||||
def is_applicable_to_api_group(self, api_group_metadata: Dict[str, Any], apis_in_group: List[Dict[str, Any]]) -> bool:
|
lookup_key: str,
|
||||||
|
global_api_spec: ParsedAPISpec, # 确保类型正确
|
||||||
|
api_group_name: Optional[str] = None
|
||||||
|
) -> Optional[APIOperationSpec]:
|
||||||
"""
|
"""
|
||||||
判断此测试阶段是否适用于给定的API分组。
|
根据查找键从提供的API规范中获取特定API操作的详细信息。
|
||||||
子类可以重写此方法以实现更复杂的适用性逻辑。
|
这个方法需要子类根据其查找逻辑来实现,或者提供一个通用的基于operationId或path+method的查找。
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
api_group_metadata: API分组的元数据 (例如 YAPI category name/id 或 Swagger tag name/description)。
|
lookup_key: 用于查找API操作的键 (例如 operationId, "METHOD /path", 或自定义键)。
|
||||||
apis_in_group: 该分组内的API端点定义列表。
|
global_api_spec: 完整的已解析API规范对象。
|
||||||
|
api_group_name: (可选) 当前API分组的名称,用于更精确的查找或作用域限定。
|
||||||
|
|
||||||
Returns:
|
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):
|
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:
|
if not match_found and hasattr(endpoint_obj, 'operation_id') and endpoint_obj.operation_id == lookup_key:
|
||||||
match_found = True
|
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:
|
if match_found:
|
||||||
self.logger.info(f"'{self.id}': 找到匹配操作 '{lookup_key}' -> Method: {endpoint_obj.method}, Path: {endpoint_obj.path}")
|
self.logger.info(f"'{self.id}': 找到匹配操作 '{lookup_key}' -> Method: {endpoint_obj.method}, Path: {endpoint_obj.path}")
|
||||||
|
|
||||||
@@ -305,7 +532,7 @@ class ExecutedStageStepResult:
|
|||||||
FAILED = "失败"
|
FAILED = "失败"
|
||||||
ERROR = "执行错误"
|
ERROR = "执行错误"
|
||||||
SKIPPED = "跳过"
|
SKIPPED = "跳过"
|
||||||
PENDING = "处理中" # 新增:表示步骤正在等待或预处理
|
PENDING = "处理中"
|
||||||
|
|
||||||
def __init__(self,
|
def __init__(self,
|
||||||
step_name: str,
|
step_name: str,
|
||||||
@@ -313,13 +540,13 @@ class ExecutedStageStepResult:
|
|||||||
message: str = "",
|
message: str = "",
|
||||||
validation_points: Optional[List[ValidationResult]] = None,
|
validation_points: Optional[List[ValidationResult]] = None,
|
||||||
duration: float = 0.0,
|
duration: float = 0.0,
|
||||||
api_call_detail: Optional[APICallDetail] = None, # 记录此步骤的API调用详情
|
api_call_detail: Optional[APICallDetail] = None,
|
||||||
extracted_outputs: Optional[Dict[str, Any]] = None,
|
extracted_outputs: Optional[Dict[str, Any]] = None,
|
||||||
description: Optional[str] = None, # <--- 添加 description
|
description: Optional[str] = None,
|
||||||
lookup_key: Optional[Union[str, Dict[str, str]]] = None, # <--- 添加 lookup_key
|
lookup_key: Optional[Union[str, Dict[str, str]]] = None,
|
||||||
resolved_endpoint: Optional[str] = None, # <--- 添加 resolved_endpoint
|
resolved_endpoint: Optional[str] = None,
|
||||||
request_details: Optional[Dict[str, Any]] = None, # <--- 添加 request_details
|
request_details: Optional[Dict[str, Any]] = None,
|
||||||
context_after_step: Optional[Dict[str, Any]] = None # <--- 添加 context_after_step
|
context_after_step: Optional[Dict[str, Any]] = None
|
||||||
):
|
):
|
||||||
self.step_name = step_name
|
self.step_name = step_name
|
||||||
self.status = status
|
self.status = status
|
||||||
@@ -329,52 +556,77 @@ class ExecutedStageStepResult:
|
|||||||
self.timestamp = time.time()
|
self.timestamp = time.time()
|
||||||
self.api_call_detail = api_call_detail
|
self.api_call_detail = api_call_detail
|
||||||
self.extracted_outputs = extracted_outputs or {}
|
self.extracted_outputs = extracted_outputs or {}
|
||||||
self.description = description # <--- 设置属性
|
self.description = description
|
||||||
self.lookup_key = lookup_key # <--- 设置属性
|
self.lookup_key = lookup_key
|
||||||
self.resolved_endpoint = resolved_endpoint # <--- 设置属性
|
self.resolved_endpoint = resolved_endpoint
|
||||||
self.request_details = request_details # <--- 设置属性
|
self.request_details = request_details
|
||||||
self.context_after_step = context_after_step # <--- 设置属性
|
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]:
|
def to_dict(self) -> Dict[str, Any]:
|
||||||
vps_details = []
|
vps_details_for_output = []
|
||||||
if self.validation_points: # self.validation_points is List[Dict[str, Any]]
|
if self.validation_points: # self.validation_points is List[ValidationResult]
|
||||||
for vp_dict in self.validation_points: # vp_dict is a Dict from ValidationResult.to_dict()
|
for vp_obj in self.validation_points: # vp_obj is a ValidationResult object
|
||||||
# Access dictionary keys instead of object attributes
|
if not isinstance(vp_obj, ValidationResult): # Defensive check
|
||||||
vp_details_content = vp_dict.get('details')
|
logger.warning(f"Step '{self.step_name}': Found non-ValidationResult item in validation_points: {type(vp_obj)}")
|
||||||
vp_passed = vp_dict.get('passed', False)
|
continue
|
||||||
vp_message = vp_dict.get('message', '')
|
|
||||||
|
|
||||||
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:
|
try:
|
||||||
# 只取部分关键信息或确保可序列化
|
# 只取部分关键信息或确保可序列化
|
||||||
if "status_code" in vp_details_content:
|
if "status_code" in details_content:
|
||||||
processed_detail["status_code"] = vp_details_content["status_code"]
|
processed_detail["status_code_in_validation"] = details_content["status_code"]
|
||||||
# 不直接序列化整个 response body 以免过大
|
# 可以添加其他从 details_content 中提取的可序列化字段
|
||||||
# You might want to add other relevant serializable fields from vp_details_content
|
|
||||||
except TypeError:
|
except TypeError:
|
||||||
# Update message if details were not serializable, though this part might be less reachable now
|
# 如果 details 无法完全序列化,更新消息
|
||||||
processed_detail["message"] = f"{vp_message} (Details not fully serializable)"
|
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 {
|
return {
|
||||||
"step_name": self.step_name,
|
"step_name": self.step_name,
|
||||||
"description": self.description, # <--- 添加到输出
|
"description": self.description,
|
||||||
"lookup_key": self.lookup_key if isinstance(self.lookup_key, str) else str(self.lookup_key), # <--- 添加到输出 (确保字符串化)
|
"lookup_key": str(self.lookup_key) if self.lookup_key is not None else None,
|
||||||
"resolved_endpoint": self.resolved_endpoint, # <--- 添加到输出
|
"resolved_endpoint": self.resolved_endpoint,
|
||||||
"status": self.status.value,
|
"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}",
|
"duration_seconds": f"{self.duration:.4f}",
|
||||||
"timestamp": time.strftime('%Y-%m-%dT%H:%M:%S%z', time.localtime(self.timestamp)),
|
"timestamp": time.strftime('%Y-%m-%dT%H:%M:%S%z', time.localtime(self.timestamp)),
|
||||||
"validation_points": vps_details,
|
"validation_points": vps_details_for_output,
|
||||||
"api_call_curl": self.api_call_detail.curl_command if self.api_call_detail else None,
|
"api_call_curl": getattr(getattr(self, 'api_call_detail', None), 'curl_command', 'N/A'),
|
||||||
"request_details": self.request_details, # <--- 添加到输出
|
"request_details": self.request_details,
|
||||||
"extracted_outputs": {k: str(v)[:200] + '...' if isinstance(v, (str, bytes)) and len(v) > 200 else v
|
"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()},
|
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_id: str,
|
||||||
stage_name: str,
|
stage_name: str,
|
||||||
api_group_metadata: Optional[Dict[str, Any]] = None,
|
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_id = stage_id
|
||||||
self.stage_name = stage_name
|
self.stage_name = stage_name
|
||||||
self.description = description
|
self.description = description
|
||||||
self.api_group_metadata = api_group_metadata
|
self.api_group_metadata = api_group_metadata
|
||||||
self.overall_status: ExecutedStageResult.Status = ExecutedStageResult.Status.PENDING
|
self.tags = tags if tags is not None else [] # <-- Store tags
|
||||||
self.start_time: datetime = datetime.now()
|
self.overall_status = overall_status
|
||||||
self.end_time: Optional[datetime] = None
|
self.message = message
|
||||||
self.duration: float = 0.0
|
|
||||||
self.message: Optional[str] = None
|
|
||||||
self.executed_steps: List[ExecutedStageStepResult] = [] # 确保初始化为空列表
|
self.executed_steps: List[ExecutedStageStepResult] = [] # 确保初始化为空列表
|
||||||
self.final_context: Optional[Dict[str, Any]] = None
|
self.final_context: Optional[Dict[str, Any]] = None
|
||||||
# executed_steps_count 应该是一个属性,或者在 to_dict 中计算
|
# 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):
|
def add_step_result(self, step_result: ExecutedStageStepResult):
|
||||||
self.executed_steps.append(step_result)
|
self.executed_steps.append(step_result)
|
||||||
|
|
||||||
def finalize_stage_result(self, final_context: Optional[Dict[str, Any]] = None):
|
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.duration = (self.end_time - self.start_time).total_seconds()
|
||||||
self.final_context = final_context
|
self.final_context = final_context
|
||||||
|
|
||||||
@@ -452,6 +708,7 @@ class ExecutedStageResult:
|
|||||||
"stage_name": self.stage_name,
|
"stage_name": self.stage_name,
|
||||||
"description": self.description, # <--- 添加 description 到输出
|
"description": self.description, # <--- 添加 description 到输出
|
||||||
"api_group_name": self.api_group_metadata.get("name", "N/A"),
|
"api_group_name": self.api_group_metadata.get("name", "N/A"),
|
||||||
|
"tags": self.tags, # <-- Added tags to output
|
||||||
"overall_status": self.overall_status.value,
|
"overall_status": self.overall_status.value,
|
||||||
"duration_seconds": f"{self.duration:.2f}",
|
"duration_seconds": f"{self.duration:.2f}",
|
||||||
"start_time": self.start_time.strftime('%Y-%m-%dT%H:%M:%S%z'),
|
"start_time": self.start_time.strftime('%Y-%m-%dT%H:%M:%S%z'),
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import importlib.util
|
|||||||
import inspect
|
import inspect
|
||||||
import logging
|
import logging
|
||||||
from typing import List, Type, Dict, Optional
|
from typing import List, Type, Dict, Optional
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
from .stage_framework import BaseAPIStage # 导入新的 BaseAPIStage
|
from .stage_framework import BaseAPIStage # 导入新的 BaseAPIStage
|
||||||
|
|
||||||
@@ -13,111 +14,105 @@ logger = logging.getLogger(__name__)
|
|||||||
|
|
||||||
class StageRegistry:
|
class StageRegistry:
|
||||||
"""
|
"""
|
||||||
负责发现、加载和管理 BaseAPIStage 子类。
|
负责发现、加载和管理所有API测试阶段 (stages)。
|
||||||
"""
|
"""
|
||||||
def __init__(self, stages_dir: Optional[str] = None):
|
def __init__(self, stages_dir: Optional[str] = None):
|
||||||
"""
|
"""
|
||||||
初始化 StageRegistry。
|
初始化 StageRegistry。
|
||||||
Args:
|
Args:
|
||||||
stages_dir: 存放自定义 BaseAPIStage Python文件的目录路径。
|
stages_dir: 存放自定义 APIStage Python文件的目录路径。
|
||||||
如果为 None 或无效路径,则不会加载任何自定义阶段。
|
如果为 None,则不会加载任何阶段。
|
||||||
"""
|
"""
|
||||||
self.logger = logging.getLogger(__name__)
|
self.stages_dir: Optional[Path] = Path(stages_dir) if stages_dir else None
|
||||||
self.stages_dir = stages_dir
|
|
||||||
self._stages: Dict[str, Type[BaseAPIStage]] = {}
|
self._stages: Dict[str, Type[BaseAPIStage]] = {}
|
||||||
self._errors: List[str] = []
|
self._errors: List[str] = []
|
||||||
|
|
||||||
if self.stages_dir and os.path.isdir(self.stages_dir):
|
if self.stages_dir:
|
||||||
self.logger.info(f"StageRegistry: 开始从目录 '{self.stages_dir}' 加载测试阶段...")
|
if not self.stages_dir.is_dir():
|
||||||
self._discover_and_load_stages()
|
logger.warning(f"指定的阶段目录不存在或不是一个目录: {self.stages_dir}")
|
||||||
if self._errors:
|
# Optionally, you could raise an error here or handle it as no stages found.
|
||||||
for error in self._errors:
|
# For now, it will just result in no stages being loaded.
|
||||||
self.logger.error(f"StageRegistry: 加载阶段时发生错误: {error}")
|
self.stages_dir = None # Prevent further processing if dir is invalid
|
||||||
self.logger.info(f"StageRegistry: 加载完成。共加载 {len(self._stages)} 个测试阶段。")
|
else:
|
||||||
elif stages_dir: # 如果提供了stages_dir但不是有效目录
|
self.discover_stages()
|
||||||
self.logger.warning(f"StageRegistry: 提供的阶段目录 '{stages_dir}' 无效或不存在。将不会加载任何自定义阶段。")
|
else:
|
||||||
else: # 如果 stages_dir 未提供
|
logger.info("没有提供阶段目录,将不加载任何自定义测试阶段。")
|
||||||
self.logger.info("StageRegistry: 未提供阶段目录,将不会加载任何自定义阶段。")
|
|
||||||
|
|
||||||
def _discover_and_load_stages(self):
|
def discover_stages(self):
|
||||||
"""发现并加载指定目录下的所有 BaseAPIStage 子类。"""
|
"""从指定的目录中发现并加载所有 BaseAPIStage 子类。"""
|
||||||
if not self.stages_dir or not os.path.isdir(self.stages_dir):
|
if not self.stages_dir or not self.stages_dir.is_dir():
|
||||||
self.logger.warning(f"StageRegistry: 阶段目录 '{self.stages_dir}' 无效或不存在,无法发现阶段。")
|
logger.debug("阶段目录未设置或无效,跳过阶段发现。")
|
||||||
return
|
return
|
||||||
|
|
||||||
self.logger.info(f"StageRegistry: 开始从目录 '{self.stages_dir}' 及其子目录发现测试阶段...")
|
self._stages = {}
|
||||||
found_count = 0
|
self._errors = []
|
||||||
# 使用 os.walk 进行递归扫描
|
logger.info(f"开始从目录发现测试阶段: {self.stages_dir}")
|
||||||
for root_dir, _, files in os.walk(self.stages_dir):
|
|
||||||
|
for root, _, files in os.walk(self.stages_dir):
|
||||||
for filename in files:
|
for filename in files:
|
||||||
if filename.endswith(".py") and not filename.startswith("__"):
|
if filename.endswith('.py') and not filename.startswith('_'):
|
||||||
module_name = filename[:-3]
|
file_path = Path(root) / filename
|
||||||
module_path = os.path.join(root_dir, filename) # 使用 root_dir
|
module_name = f"ddms_compliance_suite.stages.{file_path.stem}" # 可以根据需要调整模块命名
|
||||||
try:
|
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:
|
if spec and spec.loader:
|
||||||
module = importlib.util.module_from_spec(spec)
|
module = importlib.util.module_from_spec(spec)
|
||||||
spec.loader.exec_module(module)
|
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):
|
for name, cls in inspect.getmembers(module, inspect.isclass):
|
||||||
if issubclass(cls, BaseAPIStage) and cls is not BaseAPIStage:
|
if issubclass(cls, BaseAPIStage) and cls is not BaseAPIStage:
|
||||||
if not hasattr(cls, 'id') or not cls.id: # 检查id属性是否存在且不为空
|
if hasattr(cls, 'id') and cls.id:
|
||||||
stage_id = cls.__name__
|
|
||||||
self.logger.warning(f"测试阶段类 '{cls.__name__}' (在模块 '{module_name}' 来自路径 '{module_path}') 未定义有效 'id' 属性,将使用类名 '{stage_id}' 作为其ID。建议为每个阶段设置唯一的 'id'。")
|
|
||||||
else:
|
|
||||||
stage_id = cls.id
|
stage_id = cls.id
|
||||||
|
|
||||||
if stage_id in self._stages:
|
if stage_id in self._stages:
|
||||||
self.logger.warning(f"重复的测试阶段ID '{stage_id}' (来自类 '{cls.__name__}' 在模块 '{module_name}' 来自路径 '{module_path}')。之前的定义将被覆盖。请确保阶段ID唯一。")
|
warning_msg = f"发现重复的阶段ID '{stage_id}' 在 {file_path}。已加载的阶段来自 {self._stages[stage_id].__module__}。将被忽略。"
|
||||||
self._stages[stage_id] = cls
|
logger.warning(warning_msg)
|
||||||
found_count +=1
|
self._errors.append(warning_msg)
|
||||||
self.logger.info(f"StageRegistry: 已注册测试阶段: '{stage_id}' (类: {cls.__name__}) 从模块 '{module_name}' (路径: {module_path})")
|
|
||||||
else:
|
else:
|
||||||
self._errors.append(f"无法为文件 '{module_path}' 创建模块规范。")
|
self._stages[stage_id] = cls
|
||||||
self.logger.error(f"StageRegistry: 无法为文件 '{module_path}' 创建模块规范。")
|
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:
|
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._errors.append(error_msg)
|
||||||
self.logger.error(f"StageRegistry: {error_msg}", exc_info=True)
|
|
||||||
except Exception as e:
|
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._errors.append(error_msg)
|
||||||
self.logger.error(f"StageRegistry: {error_msg}", exc_info=True)
|
|
||||||
|
|
||||||
if found_count == 0 and not self._errors:
|
logger.info(f"测试阶段发现完成。共加载 {len(self._stages)} 个阶段。发现 {len(self._errors)} 个错误。")
|
||||||
self.logger.info(f"StageRegistry: 在 '{self.stages_dir}' 及其子目录中未找到符合条件的测试阶段文件或类。")
|
|
||||||
elif self._errors:
|
|
||||||
self.logger.warning(f"StageRegistry: 测试阶段发现过程中遇到 {len(self._errors)} 个错误。请检查日志。")
|
|
||||||
|
|
||||||
# 注意:StageRegistry 目前没有像 TestCaseRegistry 那样的排序逻辑,
|
def get_all_stages(self) -> List[Type[BaseAPIStage]]:
|
||||||
# 如果需要按特定顺序执行 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]]:
|
|
||||||
"""获取所有已注册的测试阶段类的列表。"""
|
|
||||||
return list(self._stages.values())
|
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
|
return self._errors
|
||||||
|
|
||||||
|
def clear_stages(self):
|
||||||
|
"""清空所有已加载的阶段和错误信息。"""
|
||||||
|
self._stages = {}
|
||||||
|
self._errors = []
|
||||||
|
logger.debug("StageRegistry 已清空。")
|
||||||
|
|
||||||
def reload_stages(self):
|
def reload_stages(self):
|
||||||
"""
|
"""清空并重新从目录加载所有阶段。"""
|
||||||
重新加载所有测试阶段。会清空当前已加载的阶段和错误记录。
|
self.clear_stages()
|
||||||
"""
|
if self.stages_dir:
|
||||||
self.logger.info(f"StageRegistry: 正在从目录 '{self.stages_dir}' 重新加载所有测试阶段...")
|
self.discover_stages()
|
||||||
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}' 无效或不存在。没有加载任何自定义阶段。")
|
|
||||||
else:
|
else:
|
||||||
self.logger.info("StageRegistry (重载时): 未配置阶段目录,没有加载任何自定义阶段。")
|
logger.info("没有配置阶段目录,无法重新加载阶段。")
|
||||||
File diff suppressed because it is too large
Load Diff
+2597
-2578
File diff suppressed because one or more lines are too long
+1150
-972
File diff suppressed because it is too large
Load Diff
+261
-394
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user