step half finish
This commit is contained in:
parent
e23f2856d6
commit
7333cc8a2a
8
Makefile
8
Makefile
@ -5,6 +5,14 @@
|
||||
|
||||
run:
|
||||
python run_api_tests.py --base-url http://127.0.0.1:4523/m1/6389742-6086420-default --yapi assets/doc/井筒API示例_simple.json --custom-test-cases-dir ./custom_testcases --verbose --output test_report.json >log.txt 2>&1
|
||||
run_stages:
|
||||
python run_api_tests.py \
|
||||
--base-url http://127.0.0.1:4523/m1/6389742-6086420-default \
|
||||
--yapi ./assets/doc/井筒API示例_simple.json \
|
||||
--stages-dir ./custom_stages \
|
||||
--custom-test-cases-dir ./custom_testcases \
|
||||
-v \
|
||||
-o ./test_reports/ >log_stage.txt 2>&1
|
||||
|
||||
docker_build_redhat:
|
||||
docker run --platform linux/amd64 --rm -v "$(pwd)":/app -w /app registry.access.redhat.com/ubi8/python-39 sh -c "pip3 install --no-cache-dir -r requirements.txt || true && pip3 install --no-cache-dir pyinstaller && pyinstaller --onefile --noconfirm run_api_tests.py && ./dist/run_api_tests --base-url http://host.docker.internal:4523 > ./dist/output.txt 2>&1 && chown -R $(id -u):$(id -g) dist build run_api_tests.spec || true"
|
||||
|
||||
Binary file not shown.
2957
api_call_details.md
2957
api_call_details.md
File diff suppressed because it is too large
Load Diff
Binary file not shown.
Binary file not shown.
432
custom_stages/keyword_driven_crud_stage.py
Normal file
432
custom_stages/keyword_driven_crud_stage.py
Normal file
@ -0,0 +1,432 @@
|
||||
import time
|
||||
import uuid
|
||||
import logging
|
||||
import re
|
||||
from typing import List, Dict, Any, Optional, Callable
|
||||
|
||||
from ddms_compliance_suite.stage_framework import BaseAPIStage, StageStepDefinition, APIOperationSpec, ExecutedStageResult, ExecutedStageStepResult
|
||||
from ddms_compliance_suite.test_framework_core import ValidationResult, APIResponseContext
|
||||
from ddms_compliance_suite.input_parser.parser import ParsedAPISpec
|
||||
|
||||
# =====================================================================================
|
||||
# USER CONFIGURATION: RESOURCE KEYWORD
|
||||
# =====================================================================================
|
||||
# 请用户根据实际要测试的资源修改此关键字,例如 "用户", "订单", "测井曲线" 等。
|
||||
# 这个关键字将用于在API规范的端点标题中模糊查找相关的操作。
|
||||
RESOURCE_KEYWORD = "地质单元" # <-- 【【【请修改这里!】】】
|
||||
# =====================================================================================
|
||||
|
||||
# --- Action Keywords for Discovery (Usually these can remain as is) ---
|
||||
CREATE_ACTION_KEYWORDS = ["添加", "新建", "创建"]
|
||||
LIST_ACTION_KEYWORDS = ["列表查询", "查询列表", "获取列表", "分页查询"]
|
||||
DETAIL_ACTION_KEYWORDS = ["查询详情", "获取详情", "根据ID获取"]
|
||||
UPDATE_ACTION_KEYWORDS = ["修改", "更新"]
|
||||
DELETE_ACTION_KEYWORDS = ["删除"]
|
||||
|
||||
# --- Default values that might come from config or context in a real scenario ---
|
||||
# --- 这些值也可能需要根据您的API进行调整 ---
|
||||
DEFAULT_DMS_INSTANCE_CODE_PLACEHOLDER = "your_dms_instance_code" # 示例占位符
|
||||
DEFAULT_API_VERSION_PLACEHOLDER = "1.0.0" # 示例占位符
|
||||
DEFAULT_TENANT_ID_PLACEHOLDER = "your-tenant-id" # 示例占位符
|
||||
DEFAULT_AUTHORIZATION_PLACEHOLDER = "Bearer your-test-token" # 示例占位符
|
||||
|
||||
|
||||
# --- Custom Assertion Helper Functions & Context Manipulators ---
|
||||
# 【【【重要提示】】】: 下面的这些辅助函数中的JSON路径 (如 'data.list', 'wellCommonName', 'dsid')
|
||||
# 是基于 '井筒API示例_simple.json' 的结构。
|
||||
# 当您修改 RESOURCE_KEYWORD 并适配您自己的API时,您【必须】检查并修改这些路径以匹配您API的实际响应结构。
|
||||
|
||||
def find_and_extract_id_by_name(response_ctx: APIResponseContext, stage_ctx: dict) -> ValidationResult:
|
||||
"""
|
||||
(通用版) 在列表响应中通过唯一名称查找新创建的资源,
|
||||
验证其存在,并将其唯一标识符(例如 'id', 'dsid', 'uuid')提取到阶段上下文中。
|
||||
|
||||
需要用户在 '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
|
||||
for part in path_str.split('.'):
|
||||
if isinstance(current, dict) and part in current:
|
||||
current = current[part]
|
||||
elif isinstance(current, list) and part.isdigit() and 0 <= int(part) < len(current):
|
||||
current = current[int(part)]
|
||||
else:
|
||||
return None
|
||||
return current
|
||||
|
||||
resource_list = _get_value_by_path(response_data, LIST_PATH_IN_RESPONSE)
|
||||
|
||||
if not isinstance(resource_list, list):
|
||||
return ValidationResult(passed=False, message=f"响应格式错误:期望路径 '{LIST_PATH_IN_RESPONSE}' 返回一个列表,实际得到 {type(resource_list)}。")
|
||||
|
||||
found_item = None
|
||||
for item in resource_list:
|
||||
if isinstance(item, dict) and item.get(NAME_FIELD_IN_LIST_ITEM) == unique_name_to_find:
|
||||
found_item = item
|
||||
break
|
||||
|
||||
if not found_item:
|
||||
return ValidationResult(passed=False, message=f"在列表响应中未找到名称为 '{unique_name_to_find}' (字段: {NAME_FIELD_IN_LIST_ITEM}) 的资源。")
|
||||
|
||||
resource_id = found_item.get(ID_FIELD_IN_LIST_ITEM)
|
||||
if not resource_id:
|
||||
return ValidationResult(passed=False, message=f"找到名称为 '{unique_name_to_find}' 的资源,但它缺少ID字段 '{ID_FIELD_IN_LIST_ITEM}'。")
|
||||
|
||||
# 将找到的ID存入上下文,变量名可由outputs_to_context覆盖
|
||||
stage_ctx["created_resource_id"] = resource_id
|
||||
logger.info(f"成功在列表中找到资源 '{unique_name_to_find}' 并提取其ID ({ID_FIELD_IN_LIST_ITEM}): {resource_id}。")
|
||||
return ValidationResult(passed=True, message=f"找到资源 '{unique_name_to_find}' 并提取ID: {resource_id}。建议使用 outputs_to_context 获取ID。")
|
||||
|
||||
|
||||
def check_resource_details(response_ctx: APIResponseContext, stage_ctx: dict) -> ValidationResult:
|
||||
"""
|
||||
(通用版) 验证获取到的资源详情。
|
||||
假设 'created_resource_id' 和 'unique_resource_name' 在 stage_ctx 中。
|
||||
假设响应直接就是资源对象,或者在 'data.list[0]' 或 'data' 中 (需要用户调整)。
|
||||
"""
|
||||
# 【【【请用户根据实际API修改下面的JSON路径和字段名】】】
|
||||
# 响应中资源对象的位置。留空表示响应体本身就是资源对象。
|
||||
# 例如:"data.list[0]" (如果详情接口返回带列表的结构), "data" (如果返回带data包装的结构)
|
||||
RESOURCE_OBJECT_PATH_IN_RESPONSE = "data.list[0]"
|
||||
NAME_FIELD_IN_DETAIL = "wellCommonName"
|
||||
ID_FIELD_IN_DETAIL = "dsid"
|
||||
# 【【【修改结束】】】
|
||||
|
||||
created_id = stage_ctx.get("created_resource_id")
|
||||
initial_name = stage_ctx.get("unique_resource_name")
|
||||
|
||||
response_data = response_ctx.json_content
|
||||
|
||||
def _get_value_by_path(data, path_str): # Duplicated helper for clarity, could be refactored
|
||||
if not path_str: return data # No path, return whole data
|
||||
current = data
|
||||
for part in path_str.split('.'):
|
||||
# Basic list index handling like "list[0]"
|
||||
match_list_index = re.fullmatch(r"([a-zA-Z_][a-zA-Z0-9_]*)\[(\d+)\]", part)
|
||||
if match_list_index:
|
||||
list_name, index_str = match_list_index.groups()
|
||||
index = int(index_str)
|
||||
if isinstance(current, dict) and list_name in current and isinstance(current[list_name], list) and 0 <= index < len(current[list_name]):
|
||||
current = current[list_name][index]
|
||||
else: return None
|
||||
elif isinstance(current, dict) and part in current:
|
||||
current = current[part]
|
||||
else: return None
|
||||
return current
|
||||
|
||||
resource_data = _get_value_by_path(response_data, RESOURCE_OBJECT_PATH_IN_RESPONSE)
|
||||
|
||||
if not isinstance(resource_data, dict):
|
||||
return ValidationResult(passed=False, message=f"响应格式错误:期望路径 '{RESOURCE_OBJECT_PATH_IN_RESPONSE}' 返回一个对象,实际得到 {type(resource_data)}。")
|
||||
|
||||
if resource_data.get(ID_FIELD_IN_DETAIL) != created_id:
|
||||
return ValidationResult(passed=False, message=f"ID不匹配。期望 {created_id}, 得到 {resource_data.get(ID_FIELD_IN_DETAIL)} (字段: {ID_FIELD_IN_DETAIL})。")
|
||||
if NAME_FIELD_IN_DETAIL and resource_data.get(NAME_FIELD_IN_DETAIL) != initial_name: # Name check is optional
|
||||
return ValidationResult(passed=False, message=f"名称不匹配。期望 '{initial_name}', 得到 '{resource_data.get(NAME_FIELD_IN_DETAIL)}' (字段: {NAME_FIELD_IN_DETAIL})。")
|
||||
|
||||
return ValidationResult(passed=True, message=f"资源详情 (ID: {created_id}) 校验成功。")
|
||||
|
||||
|
||||
def check_resource_updated_details(response_ctx: APIResponseContext, stage_ctx: dict) -> ValidationResult:
|
||||
"""
|
||||
(通用版) 验证更新后的资源详情。
|
||||
假设 'created_resource_id' 和 'updated_resource_name' 在 stage_ctx 中。
|
||||
"""
|
||||
# 【【【请用户根据实际API修改下面的JSON路径和字段名】】】
|
||||
RESOURCE_OBJECT_PATH_IN_RESPONSE = "data.list[0]"
|
||||
NAME_FIELD_IN_DETAIL = "wellCommonName"
|
||||
ID_FIELD_IN_DETAIL = "dsid"
|
||||
# 【【【修改结束】】】
|
||||
|
||||
created_id = stage_ctx.get("created_resource_id")
|
||||
updated_name = stage_ctx.get("updated_resource_name")
|
||||
|
||||
# (Code is very similar to check_resource_details, could be refactored if desired)
|
||||
response_data = response_ctx.json_content
|
||||
def _get_value_by_path(data, path_str):
|
||||
if not path_str: return data
|
||||
current = data
|
||||
for part in path_str.split('.'):
|
||||
match_list_index = re.fullmatch(r"([a-zA-Z_][a-zA-Z0-9_]*)\[(\d+)\]", part)
|
||||
if match_list_index:
|
||||
list_name, index_str = match_list_index.groups()
|
||||
index = int(index_str)
|
||||
if isinstance(current, dict) and list_name in current and isinstance(current[list_name], list) and 0 <= index < len(current[list_name]):
|
||||
current = current[list_name][index]
|
||||
else: return None
|
||||
elif isinstance(current, dict) and part in current:
|
||||
current = current[part]
|
||||
else: return None
|
||||
return current
|
||||
resource_data = _get_value_by_path(response_data, RESOURCE_OBJECT_PATH_IN_RESPONSE)
|
||||
|
||||
if not isinstance(resource_data, dict):
|
||||
return ValidationResult(passed=False, message=f"更新后详情响应格式错误:期望路径 '{RESOURCE_OBJECT_PATH_IN_RESPONSE}' 返回一个对象,实际得到 {type(resource_data)}。")
|
||||
|
||||
if resource_data.get(ID_FIELD_IN_DETAIL) != created_id:
|
||||
return ValidationResult(passed=False, message=f"更新后ID不匹配。期望 {created_id}, 得到 {resource_data.get(ID_FIELD_IN_DETAIL)}。")
|
||||
if NAME_FIELD_IN_DETAIL and resource_data.get(NAME_FIELD_IN_DETAIL) != updated_name:
|
||||
return ValidationResult(passed=False, message=f"更新后名称不匹配。期望 '{updated_name}', 得到 '{resource_data.get(NAME_FIELD_IN_DETAIL)}'。")
|
||||
|
||||
return ValidationResult(passed=True, message=f"更新后资源详情 (ID: {created_id}) 校验成功,名称为 '{updated_name}'。")
|
||||
|
||||
|
||||
class KeywordDrivenCRUDStage(BaseAPIStage):
|
||||
id = "keyword_driven_crud_example"
|
||||
name = "Keyword-Driven Generic CRUD Stage Example"
|
||||
description = (
|
||||
"Demonstrates a CRUD (Create, List, Read, Update, Delete) flow. "
|
||||
"This stage dynamically finds API operations based on a configurable RESOURCE_KEYWORD "
|
||||
"and predefined action keywords (e.g., '添加', '查询列表'). "
|
||||
"IMPORTANT: User MUST configure RESOURCE_KEYWORD at the top of this file. "
|
||||
"Request bodies, response paths in assertions, and 'outputs_to_context' "
|
||||
"are EXAMPLES based on '井筒API示例_simple.json' and WILL LIKELY NEED MODIFICATION "
|
||||
"to match your specific API's structure."
|
||||
)
|
||||
tags = ["crud", "keyword_driven", "example"]
|
||||
continue_on_failure = False # Set to True if you want to attempt all steps even if one fails
|
||||
|
||||
# This will be populated by is_applicable_to_api_group
|
||||
discovered_op_keys: dict = {}
|
||||
# This will be populated in before_stage
|
||||
steps: list = []
|
||||
|
||||
def _find_operation_title_or_id(self, endpoints: list, resource_kw: str, action_kws: list, http_method: str | None = None) -> str | None:
|
||||
"""Tries to find an endpoint by matching keywords in its title and optionally method."""
|
||||
self.logger.debug(f"Searching for op with resource='{resource_kw}', actions='{action_kws}', method='{http_method}'")
|
||||
for ep_obj in endpoints: # ep_obj is YAPIEndpoint or SwaggerEndpoint
|
||||
# YAPI uses 'title', Swagger often uses 'summary' or 'operationId'
|
||||
# We prioritize 'title' (from YAPI) then 'summary', then 'operationId' as lookup keys
|
||||
title_to_check = getattr(ep_obj, 'title', None)
|
||||
summary_to_check = getattr(ep_obj, 'summary', None)
|
||||
op_id_to_check = getattr(ep_obj, 'operation_id', None)
|
||||
|
||||
# Determine primary text for keyword matching
|
||||
text_for_matching = title_to_check or summary_to_check or op_id_to_check
|
||||
if not text_for_matching:
|
||||
continue
|
||||
|
||||
# The key we will use to lookup the endpoint later in the orchestrator
|
||||
# The orchestrator uses 'title' for YAPI and 'operationId' or (method+path) for Swagger
|
||||
# For simplicity in this dynamic stage, we will try to return YAPI's 'title' if available and it matches,
|
||||
# otherwise 'operationId' if available and it matches.
|
||||
# If using Swagger and 'operationId' is preferred, adjust logic or ensure operationIds are descriptive.
|
||||
|
||||
lookup_key_to_return = title_to_check # Default to title (YAPI primary)
|
||||
|
||||
text_lower = text_for_matching.lower()
|
||||
resource_kw_lower = resource_kw.lower()
|
||||
|
||||
if resource_kw_lower in text_lower:
|
||||
if any(action_kw.lower() in text_lower for action_kw in action_kws):
|
||||
current_ep_method = getattr(ep_obj, 'method', '').upper()
|
||||
if http_method and current_ep_method == http_method.upper():
|
||||
self.logger.info(f"Discovered endpoint: Key='{lookup_key_to_return or op_id_to_check}', MatchedText='{text_for_matching}' (for resource='{resource_kw}', action='{action_kws}', method='{http_method}')")
|
||||
return lookup_key_to_return or op_id_to_check # Return title or operationId
|
||||
elif not http_method:
|
||||
self.logger.info(f"Discovered endpoint: Key='{lookup_key_to_return or op_id_to_check}', MatchedText='{text_for_matching}' (for resource='{resource_kw}', action='{action_kws}', any method)")
|
||||
return lookup_key_to_return or op_id_to_check
|
||||
self.logger.warning(f"No operation found for resource='{resource_kw}', actions='{action_kws}', method='{http_method}'")
|
||||
return None
|
||||
|
||||
def is_applicable_to_api_group(self, api_group_name: str | None, global_api_spec: ParsedAPISpec) -> bool:
|
||||
self.logger.info(f"Checking applicability of '{self.name}' (RESOURCE_KEYWORD: '{RESOURCE_KEYWORD}') for API group '{api_group_name}'.")
|
||||
if not global_api_spec or not global_api_spec.endpoints:
|
||||
self.logger.warning(f"'{self.name}' cannot determine applicability: global_api_spec or its endpoints are missing.")
|
||||
return False
|
||||
|
||||
# Reset for this applicability check
|
||||
self.discovered_op_keys = {}
|
||||
all_endpoints = global_api_spec.endpoints
|
||||
|
||||
self.discovered_op_keys["create"] = self._find_operation_title_or_id(all_endpoints, RESOURCE_KEYWORD, CREATE_ACTION_KEYWORDS, "POST")
|
||||
self.discovered_op_keys["list"] = self._find_operation_title_or_id(all_endpoints, RESOURCE_KEYWORD, LIST_ACTION_KEYWORDS, "POST") # Assuming list is POST for this example
|
||||
self.discovered_op_keys["detail"] = self._find_operation_title_or_id(all_endpoints, RESOURCE_KEYWORD, DETAIL_ACTION_KEYWORDS, "GET")
|
||||
self.discovered_op_keys["update"] = self._find_operation_title_or_id(all_endpoints, RESOURCE_KEYWORD, UPDATE_ACTION_KEYWORDS, "PUT")
|
||||
self.discovered_op_keys["delete"] = self._find_operation_title_or_id(all_endpoints, RESOURCE_KEYWORD, DELETE_ACTION_KEYWORDS, "DELETE")
|
||||
|
||||
missing_ops = [op_type for op_type, key_val in self.discovered_op_keys.items() if not key_val]
|
||||
|
||||
if not missing_ops:
|
||||
self.logger.info(f"'{self.name}' is APPLICABLE for group '{api_group_name}'. All CRUD operations found for '{RESOURCE_KEYWORD}'. Discovered keys: {self.discovered_op_keys}")
|
||||
return True
|
||||
else:
|
||||
self.logger.warning(f"'{self.name}' is NOT APPLICABLE for group '{api_group_name}'. Missing operations for '{RESOURCE_KEYWORD}': {missing_ops}. Discovered keys: {self.discovered_op_keys}")
|
||||
return False
|
||||
|
||||
def before_stage(self, stage_context: dict, global_api_spec: ParsedAPISpec, api_group_name: str | None):
|
||||
self.logger.info(f"Starting stage '{self.name}' for API group '{api_group_name}'. Discovered op keys: {self.discovered_op_keys}")
|
||||
|
||||
run_timestamp = int(time.time())
|
||||
run_uuid_short = uuid.uuid4().hex[:6]
|
||||
|
||||
# Generate unique names for this run, using the configured RESOURCE_KEYWORD
|
||||
# Ensure RESOURCE_KEYWORD is simple enough for this concatenation.
|
||||
safe_resource_keyword_part = re.sub(r'\W+', '', RESOURCE_KEYWORD) # Remove non-alphanumeric for safety
|
||||
|
||||
unique_name_for_run = f"Test_{safe_resource_keyword_part}_{run_timestamp}_{run_uuid_short}"
|
||||
updated_name_for_run = f"{unique_name_for_run}_UPDATED"
|
||||
|
||||
stage_context["unique_resource_name"] = unique_name_for_run
|
||||
stage_context["updated_resource_name"] = updated_name_for_run
|
||||
|
||||
# These are placeholders and likely need to be adapted or fetched from config/context
|
||||
stage_context["dms_instance_code"] = DEFAULT_DMS_INSTANCE_CODE_PLACEHOLDER
|
||||
stage_context["api_version"] = DEFAULT_API_VERSION_PLACEHOLDER
|
||||
|
||||
self.logger.info(f"Initial stage context: {stage_context}")
|
||||
|
||||
if not all(self.discovered_op_keys.values()):
|
||||
self.logger.error("Cannot build steps as not all operation keys were discovered. This should have been caught by is_applicable. Stage will have no steps.")
|
||||
self.steps = []
|
||||
return
|
||||
|
||||
# --- Dynamically build steps using discovered keys ---
|
||||
# 【【【重要提示】】】: 下面的 request_overrides (尤其是 'body') 和 'outputs_to_context'
|
||||
# 是基于 '井筒API示例_simple.json' (当RESOURCE_KEYWORD="地质单元"时)的结构。
|
||||
# 当您修改 RESOURCE_KEYWORD 并用于您自己的API时,
|
||||
# 您【必须】相应地修改这些 'body' 结构和 'outputs_to_context' 中的JSON路径。
|
||||
self.steps = [
|
||||
StageStepDefinition(
|
||||
name=f"Add New {RESOURCE_KEYWORD}",
|
||||
endpoint_spec_lookup_key=self.discovered_op_keys["create"],
|
||||
request_overrides={
|
||||
"path_params": {"dms_instance_code": "{{stage_context.dms_instance_code}}"}, # Example path param
|
||||
"headers": {"tenant-id": DEFAULT_TENANT_ID_PLACEHOLDER, "Authorization": DEFAULT_AUTHORIZATION_PLACEHOLDER},
|
||||
"body": { # EXAMPLE BODY - MODIFY FOR YOUR API
|
||||
"version": "{{stage_context.api_version}}",
|
||||
"data": [{
|
||||
"bsflag": 0, # Field specific to GeoUnit example
|
||||
"wellCommonName": "{{stage_context.unique_resource_name}}", # Field specific to GeoUnit example
|
||||
"wellId": f"ExampleWellID_{run_timestamp}", # Field specific to GeoUnit example
|
||||
"dataRegion": "TEST_REGION" # Field specific to GeoUnit example
|
||||
}]
|
||||
}
|
||||
},
|
||||
expected_status_codes=[200], # Modify as per your API
|
||||
# outputs_to_context: {"created_resource_id": "body.data[0].dsid"} # MODIFY FOR YOUR API
|
||||
),
|
||||
StageStepDefinition(
|
||||
name=f"List and Find Created {RESOURCE_KEYWORD}",
|
||||
endpoint_spec_lookup_key=self.discovered_op_keys["list"],
|
||||
request_overrides={
|
||||
"path_params": { # Example path params
|
||||
"dms_instance_code": "{{stage_context.dms_instance_code}}",
|
||||
"version": "{{stage_context.api_version}}"
|
||||
},
|
||||
"headers": {"tenant-id": DEFAULT_TENANT_ID_PLACEHOLDER, "Authorization": DEFAULT_AUTHORIZATION_PLACEHOLDER},
|
||||
"query_params": {"pageNo": 1, "pageSize": 10}, # Example query params
|
||||
"body": { # EXAMPLE BODY for a POST list query - MODIFY FOR YOUR API
|
||||
"isSearchCount": True,
|
||||
"query": {
|
||||
"fields": ["dsid", "wellCommonName"], # Example fields - MODIFY
|
||||
"filter": { # Example filter - MODIFY
|
||||
"key": "wellCommonName",
|
||||
"symbol": "=",
|
||||
"realValue": ["{{stage_context.unique_resource_name}}"]
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
expected_status_codes=[200],
|
||||
response_assertions=[find_and_extract_id_by_name], # Uses generic helper, ensure its internal paths are also updated!
|
||||
outputs_to_context={"created_resource_id": "body.data.list[0].dsid"} # EXAMPLE output - MODIFY
|
||||
),
|
||||
StageStepDefinition(
|
||||
name=f"Get Created {RESOURCE_KEYWORD} Details",
|
||||
endpoint_spec_lookup_key=self.discovered_op_keys["detail"],
|
||||
request_overrides={
|
||||
"path_params": { # Example path params - MODIFY
|
||||
"dms_instance_code": "{{stage_context.dms_instance_code}}",
|
||||
"version": "{{stage_context.api_version}}",
|
||||
"id": "{{stage_context.created_resource_id}}" # Assumes 'id' is the path param name
|
||||
},
|
||||
"headers": {"tenant-id": DEFAULT_TENANT_ID_PLACEHOLDER, "Authorization": DEFAULT_AUTHORIZATION_PLACEHOLDER}
|
||||
},
|
||||
expected_status_codes=[200],
|
||||
response_assertions=[check_resource_details] # Uses generic helper, ensure its internal paths are also updated!
|
||||
),
|
||||
StageStepDefinition(
|
||||
name=f"Update Created {RESOURCE_KEYWORD}",
|
||||
endpoint_spec_lookup_key=self.discovered_op_keys["update"],
|
||||
request_overrides={
|
||||
"path_params": {"dms_instance_code": "{{stage_context.dms_instance_code}}"}, # Example
|
||||
"query_params": {"id": "{{stage_context.created_resource_id}}"}, # Example if ID is in query for PUT
|
||||
"headers": {"tenant-id": DEFAULT_TENANT_ID_PLACEHOLDER, "Authorization": DEFAULT_AUTHORIZATION_PLACEHOLDER},
|
||||
"body": { # EXAMPLE BODY - MODIFY FOR YOUR API
|
||||
"id": "{{stage_context.created_resource_id}}",
|
||||
"version": "{{stage_context.api_version}}",
|
||||
"wellCommonName": "{{stage_context.updated_resource_name}}", # Example field
|
||||
"dataRegion": "TEST_REGION_UPDATED" # Example field
|
||||
# Add other required fields for your API's update operation
|
||||
}
|
||||
},
|
||||
expected_status_codes=[200],
|
||||
),
|
||||
StageStepDefinition(
|
||||
name=f"Get Updated {RESOURCE_KEYWORD} Details",
|
||||
endpoint_spec_lookup_key=self.discovered_op_keys["detail"],
|
||||
request_overrides={
|
||||
"path_params": {
|
||||
"dms_instance_code": "{{stage_context.dms_instance_code}}",
|
||||
"version": "{{stage_context.api_version}}",
|
||||
"id": "{{stage_context.created_resource_id}}"
|
||||
},
|
||||
"headers": {"tenant-id": DEFAULT_TENANT_ID_PLACEHOLDER, "Authorization": DEFAULT_AUTHORIZATION_PLACEHOLDER}
|
||||
},
|
||||
expected_status_codes=[200],
|
||||
response_assertions=[check_resource_updated_details] # Uses generic helper, check paths!
|
||||
),
|
||||
StageStepDefinition(
|
||||
name=f"Delete Created {RESOURCE_KEYWORD}",
|
||||
endpoint_spec_lookup_key=self.discovered_op_keys["delete"],
|
||||
request_overrides={
|
||||
"path_params": {"dms_instance_code": "{{stage_context.dms_instance_code}}"}, # Example
|
||||
"query_params": {"id": "{{stage_context.created_resource_id}}"}, # Example if ID is in query for DELETE
|
||||
"headers": {"tenant-id": DEFAULT_TENANT_ID_PLACEHOLDER, "Authorization": DEFAULT_AUTHORIZATION_PLACEHOLDER},
|
||||
"body": { # EXAMPLE BODY for delete (if it takes a body) - MODIFY FOR YOUR API
|
||||
"version": "{{stage_context.api_version}}",
|
||||
"data": ["{{stage_context.created_resource_id}}"]
|
||||
}
|
||||
},
|
||||
expected_status_codes=[204], # Or 204 No Content, etc.
|
||||
),
|
||||
StageStepDefinition(
|
||||
name=f"Verify {RESOURCE_KEYWORD} Deletion",
|
||||
endpoint_spec_lookup_key=self.discovered_op_keys["detail"], # Try to get it again
|
||||
request_overrides={
|
||||
"path_params": {
|
||||
"dms_instance_code": "{{stage_context.dms_instance_code}}",
|
||||
"version": "{{stage_context.api_version}}",
|
||||
"id": "{{stage_context.created_resource_id}}"
|
||||
},
|
||||
"headers": {"tenant-id": DEFAULT_TENANT_ID_PLACEHOLDER, "Authorization": DEFAULT_AUTHORIZATION_PLACEHOLDER}
|
||||
},
|
||||
expected_status_codes=[404], # Expect Not Found
|
||||
),
|
||||
]
|
||||
|
||||
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}")
|
||||
Binary file not shown.
@ -14,7 +14,7 @@ class StatusCode200Check(BaseAPITestCase):
|
||||
# applicable_paths_regex = None
|
||||
execution_order = 1 # 执行顺序
|
||||
is_critical_setup_test = True
|
||||
use_llm_for_body: bool = True
|
||||
# use_llm_for_body: bool = True
|
||||
# use_llm_for_path_params: bool = True
|
||||
# use_llm_for_query_params: bool = True
|
||||
# use_llm_for_headers: bool = True
|
||||
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
BIN
ddms_compliance_suite/__pycache__/stage_registry.cpython-312.pyc
Normal file
BIN
ddms_compliance_suite/__pycache__/stage_registry.cpython-312.pyc
Normal file
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
228
ddms_compliance_suite/scenario_framework.py
Normal file
228
ddms_compliance_suite/scenario_framework.py
Normal file
@ -0,0 +1,228 @@
|
||||
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)
|
||||
90
ddms_compliance_suite/scenario_registry.py
Normal file
90
ddms_compliance_suite/scenario_registry.py
Normal file
@ -0,0 +1,90 @@
|
||||
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) # 返回副本
|
||||
454
ddms_compliance_suite/stage_framework.py
Normal file
454
ddms_compliance_suite/stage_framework.py
Normal file
@ -0,0 +1,454 @@
|
||||
"""
|
||||
API 阶段测试框架模块
|
||||
"""
|
||||
import logging
|
||||
import time
|
||||
from typing import List, Dict, Any, Callable, Optional, Union
|
||||
from enum import Enum
|
||||
|
||||
# Add Pydantic BaseModel for APIOperationSpec
|
||||
from pydantic import BaseModel
|
||||
|
||||
from .test_framework_core import ValidationResult, APIResponseContext
|
||||
from .api_caller.caller import APICallDetail
|
||||
# Import ParsedAPISpec and endpoint types for type hinting and usage
|
||||
from .input_parser.parser import ParsedAPISpec, YAPIEndpoint, SwaggerEndpoint
|
||||
|
||||
# 尝试从 .llm_utils 导入,如果失败则 LLMService 为 None
|
||||
try:
|
||||
from .llm_utils.llm_service import LLMService
|
||||
except ImportError:
|
||||
LLMService = None
|
||||
logging.getLogger(__name__).info("LLMService not found in stage_framework, LLM related features for stages might be limited.")
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# 定义 APIOperationSpec
|
||||
class APIOperationSpec(BaseModel):
|
||||
method: str
|
||||
path: str
|
||||
spec: Dict[str, Any] # 原始API端点定义字典
|
||||
operation_id: Optional[str] = None
|
||||
summary: Optional[str] = None
|
||||
description: Optional[str] = None
|
||||
|
||||
# 默认的操作类型关键字映射
|
||||
# 键是标准化的操作类型,值是可能出现在API标题中的关键字列表
|
||||
DEFAULT_OPERATION_KEYWORDS: Dict[str, List[str]] = {
|
||||
"add": ["添加", "创建", "新增", "新建", "create", "add", "new"],
|
||||
"delete": ["删除", "移除", "delete", "remove"],
|
||||
"update": ["修改", "更新", "编辑", "update", "edit", "put"],
|
||||
"list_query": ["列表", "查询", "获取列表", "搜索", "list", "query", "search", "getall", "getlist"],
|
||||
"detail_query": ["详情", "获取单个", "getone", "detail", "getbyid"],
|
||||
# 可以根据需要添加更多通用操作类型
|
||||
}
|
||||
|
||||
|
||||
class StageStepDefinition:
|
||||
"""定义API测试阶段中的单个步骤。"""
|
||||
def __init__(self,
|
||||
name: str,
|
||||
endpoint_spec_lookup_key: Union[str, Dict[str, str]], # 例如 "GET /pets/{petId}" 或 {"method": "GET", "path": "/pets/{petId}"} 或操作类型 "add"
|
||||
description: Optional[str] = None, # <--- 添加 description 参数
|
||||
request_overrides: Optional[Dict[str, Any]] = None,
|
||||
expected_status_codes: Optional[List[int]] = None,
|
||||
response_assertions: Optional[List[Callable[[APIResponseContext, Dict[str, Any]], List[ValidationResult]]]] = None,
|
||||
outputs_to_context: Optional[Dict[str, str]] = None,
|
||||
order: int = 0): # 新增执行顺序
|
||||
self.name = name
|
||||
self.endpoint_spec_lookup_key = endpoint_spec_lookup_key
|
||||
self.description = description # <--- 设置 description 属性
|
||||
self.request_overrides = request_overrides or {}
|
||||
self.expected_status_codes = expected_status_codes or []
|
||||
self.response_assertions = response_assertions or []
|
||||
self.outputs_to_context = outputs_to_context or {}
|
||||
self.order = order
|
||||
|
||||
if not isinstance(self.endpoint_spec_lookup_key, (str, dict)):
|
||||
raise ValueError("StageStepDefinition: endpoint_spec_lookup_key must be a string (operation type or method/path) or a dict {'method': 'X', 'path': 'Y'}")
|
||||
|
||||
|
||||
class BaseAPIStage:
|
||||
"""
|
||||
API测试阶段的基类。
|
||||
一个测试阶段通常针对一个API分组(如YAPI中的一个分类,或Swagger中的一个Tag下的所有API),
|
||||
并定义了一系列有序的API调用步骤来完成一个完整的业务流程或集成测试。
|
||||
"""
|
||||
id: Optional[str] = None # 唯一ID,例如 "TC_USER_CRUD_STAGE"
|
||||
name: str = "Unnamed API Stage"
|
||||
description: str = ""
|
||||
tags: List[str] = []
|
||||
|
||||
# 由子类定义,表示此Stage中的API调用步骤
|
||||
steps: List[StageStepDefinition] = []
|
||||
|
||||
|
||||
def __init__(self,
|
||||
api_group_metadata: Dict[str, Any],
|
||||
apis_in_group: List[Dict[str, Any]], # 当前分组内所有API的定义列表 (字典格式)
|
||||
llm_service: Optional[LLMService] = None,
|
||||
global_api_spec: Optional[ParsedAPISpec] = None, # <--- 修改类型注解
|
||||
operation_keywords: Optional[Dict[str, List[str]]] = None):
|
||||
self.logger = logging.getLogger(f"{__name__}.{self.__class__.__name__}")
|
||||
self.current_api_group_metadata = api_group_metadata
|
||||
self.current_apis_in_group = apis_in_group # 这些应该是已经解析好的API定义字典
|
||||
self.llm_service = llm_service
|
||||
self.global_api_spec = global_api_spec # 保留 Optional[ParsedAPISpec]
|
||||
|
||||
self._operation_keywords = operation_keywords or DEFAULT_OPERATION_KEYWORDS
|
||||
self._matched_endpoints: Dict[str, Dict[str, Any]] = {} # 存储按操作类型匹配到的端点定义
|
||||
self._match_api_endpoints_in_group() # 初始化时自动匹配
|
||||
|
||||
# 确保子类定义了ID
|
||||
if not self.id:
|
||||
self.id = self.__class__.__name__
|
||||
self.logger.warning(f"BaseAPIStage subclass '{self.__class__.__name__}' does not have an 'id' attribute defined. Defaulting to class name: '{self.id}'. It is recommended to set a unique id.")
|
||||
|
||||
# 对步骤进行排序
|
||||
self.steps = sorted(self.steps, key=lambda s: s.order)
|
||||
|
||||
|
||||
def is_applicable_to_api_group(self, api_group_metadata: Dict[str, Any], apis_in_group: List[Dict[str, Any]]) -> bool:
|
||||
"""
|
||||
判断此测试阶段是否适用于给定的API分组。
|
||||
子类可以重写此方法以实现更复杂的适用性逻辑。
|
||||
|
||||
Args:
|
||||
api_group_metadata: API分组的元数据 (例如 YAPI category name/id 或 Swagger tag name/description)。
|
||||
apis_in_group: 该分组内的API端点定义列表。
|
||||
|
||||
Returns:
|
||||
True 如果此阶段适用于该API分组,否则 False。
|
||||
"""
|
||||
return True # 默认应用于所有分组
|
||||
|
||||
def _match_api_endpoints_in_group(self):
|
||||
"""
|
||||
在当前API分组 (`self.current_apis_in_group`) 中,根据API标题和关键字匹配操作类型。
|
||||
并将匹配到的API端点定义存储在 `self._matched_endpoints` 中。
|
||||
"""
|
||||
self.logger.debug(f"'{self.id}': Starting API endpoint matching within group '{self.current_api_group_metadata.get('name', 'UnknownGroup')}'. Found {len(self.current_apis_in_group)} APIs in group.")
|
||||
if not self.current_apis_in_group:
|
||||
self.logger.warning(f"'{self.id}': No APIs found in the current group '{self.current_api_group_metadata.get('name', 'UnknownGroup')}' to match against.")
|
||||
return
|
||||
|
||||
self._matched_endpoints = {} # 重置
|
||||
|
||||
# 尝试从API定义中获取标题的优先级
|
||||
title_keys_priority = ['title', 'summary', 'operationId', 'description']
|
||||
|
||||
for api_def in self.current_apis_in_group:
|
||||
if not isinstance(api_def, dict):
|
||||
self.logger.warning(f"'{self.id}': Found non-dict API definition in group, skipping: {type(api_def)}")
|
||||
continue
|
||||
|
||||
api_title = None
|
||||
for key in title_keys_priority:
|
||||
title_candidate = api_def.get(key)
|
||||
if isinstance(title_candidate, str) and title_candidate.strip():
|
||||
api_title = title_candidate.strip()
|
||||
break
|
||||
|
||||
if not api_title:
|
||||
self.logger.debug(f"'{self.id}': API definition (method: {api_def.get('method')}, path: {api_def.get('path')}) has no suitable title/summary for matching. Skipping.")
|
||||
continue
|
||||
|
||||
api_method_path_log = f"(Method: {api_def.get('method', 'N/A')}, Path: {api_def.get('path', 'N/A')}, Title: '{api_title}')"
|
||||
|
||||
for op_type, keywords in self._operation_keywords.items():
|
||||
if op_type in self._matched_endpoints: # 如果该操作类型已经匹配到了,则跳过 (每个操作类型只取第一个匹配项)
|
||||
continue
|
||||
|
||||
for keyword in keywords:
|
||||
if keyword.lower() in api_title.lower():
|
||||
self._matched_endpoints[op_type] = api_def
|
||||
self.logger.info(f"'{self.id}': Matched API {api_method_path_log} to operation type '{op_type}' based on keyword '{keyword}'.")
|
||||
break # 当前操作类型的关键字匹配成功,跳到下一个操作类型
|
||||
if op_type in self._matched_endpoints: # 再次检查,因为可能在内层循环break
|
||||
continue # 跳到下一个操作类型
|
||||
|
||||
self.logger.debug(f"'{self.id}': Finished API endpoint matching. Matched operations: {list(self._matched_endpoints.keys())}")
|
||||
if not self._matched_endpoints:
|
||||
self.logger.warning(f"'{self.id}': No API endpoints were matched to any operation type within the group '{self.current_api_group_metadata.get('name', 'N/A')}'. Stage execution might fail if steps rely on matched operations.")
|
||||
|
||||
|
||||
def get_api_spec_for_operation(self, lookup_key: str, global_api_spec: ParsedAPISpec, api_group_name: Optional[str] = None, required: bool = True) -> Optional[APIOperationSpec]:
|
||||
"""
|
||||
获取为指定查找键 (API标题、操作ID或 "METHOD /path") 匹配到的API端点定义。
|
||||
此方法现在直接从 global_api_spec.endpoints 中查找。
|
||||
|
||||
Args:
|
||||
lookup_key: API的查找键 (通常是标题、操作ID, 或 "METHOD /path" 字符串)。
|
||||
global_api_spec: 已解析的完整API规范对象。
|
||||
api_group_name: 当前API分组的名称 (可选, 主要用于日志)。
|
||||
required: 如果为True且未找到匹配的API,则记录错误。
|
||||
|
||||
Returns:
|
||||
APIOperationSpec 实例,如果未找到则为None。
|
||||
"""
|
||||
if not global_api_spec or not global_api_spec.endpoints:
|
||||
self.logger.error(f"'{self.id}': global_api_spec 或其端点列表为空,无法查找操作 '{lookup_key}'。")
|
||||
if required:
|
||||
self.logger.error(f"'{self.id}': 未找到必需的操作 '{lookup_key}',因为API规范为空或无端点。")
|
||||
return None
|
||||
|
||||
self.logger.info(f"'{self.id}': 正在从 global_api_spec 中查找操作,键: '{lookup_key}', API组: '{api_group_name}'")
|
||||
|
||||
for endpoint_obj in global_api_spec.endpoints:
|
||||
# endpoint_obj is YAPIEndpoint or SwaggerEndpoint
|
||||
match_found = False
|
||||
# 1. Check by "METHOD /path" string
|
||||
method_path_key = f"{endpoint_obj.method.upper()} {endpoint_obj.path}"
|
||||
if method_path_key == lookup_key:
|
||||
match_found = True
|
||||
|
||||
# 2. Check by title
|
||||
if not match_found and hasattr(endpoint_obj, 'title') and endpoint_obj.title == lookup_key:
|
||||
match_found = True
|
||||
|
||||
# 3. Check by operationId
|
||||
if not match_found and hasattr(endpoint_obj, 'operation_id') and endpoint_obj.operation_id == lookup_key:
|
||||
match_found = True
|
||||
|
||||
if match_found:
|
||||
self.logger.info(f"'{self.id}': 找到匹配操作 '{lookup_key}' -> Method: {endpoint_obj.method}, Path: {endpoint_obj.path}")
|
||||
|
||||
raw_spec_dict: Dict[str, Any] = {}
|
||||
if hasattr(endpoint_obj, 'to_dict') and callable(endpoint_obj.to_dict):
|
||||
raw_spec_dict = endpoint_obj.to_dict()
|
||||
elif isinstance(endpoint_obj, dict): # Should not happen if global_api_spec.endpoints are objects
|
||||
raw_spec_dict = endpoint_obj
|
||||
else:
|
||||
self.logger.warning(f"'{self.id}': 匹配的端点对象 '{lookup_key}' (类型: {type(endpoint_obj)}) 缺少 to_dict() 方法且不是字典,无法获取完整规格。")
|
||||
# Fallback: construct spec from known attributes if possible
|
||||
raw_spec_dict = {
|
||||
"method": endpoint_obj.method,
|
||||
"path": endpoint_obj.path,
|
||||
"title": getattr(endpoint_obj, 'title', None),
|
||||
"summary": getattr(endpoint_obj, 'summary', None),
|
||||
"description": getattr(endpoint_obj, 'description', None),
|
||||
"operationId": getattr(endpoint_obj, 'operation_id', None),
|
||||
"parameters": getattr(endpoint_obj, 'parameters', []) if hasattr(endpoint_obj, 'parameters') else [],
|
||||
"requestBody": getattr(endpoint_obj, 'request_body', None) if hasattr(endpoint_obj, 'request_body') else None,
|
||||
"responses": getattr(endpoint_obj, 'responses', {}) if hasattr(endpoint_obj, 'responses') else {}
|
||||
}
|
||||
|
||||
|
||||
return APIOperationSpec(
|
||||
method=endpoint_obj.method,
|
||||
path=endpoint_obj.path,
|
||||
spec=raw_spec_dict,
|
||||
operation_id=getattr(endpoint_obj, 'operation_id', None),
|
||||
summary=getattr(endpoint_obj, 'summary', None) or getattr(endpoint_obj, 'title', None), # Prioritize summary
|
||||
description=getattr(endpoint_obj, 'description', None)
|
||||
)
|
||||
|
||||
# If no match found after iterating all endpoints
|
||||
if required:
|
||||
self.logger.error(f"'{self.id}': 在 global_api_spec 中未找到必需的操作,查找键: '{lookup_key}'. (API组: '{api_group_name}')")
|
||||
else:
|
||||
self.logger.debug(f"'{self.id}': 在 global_api_spec 中未找到可选的操作,查找键: '{lookup_key}'. (API组: '{api_group_name}')")
|
||||
return None
|
||||
|
||||
def get_endpoint_lookup_key_for_operation(self, operation_type: str, required: bool = True) -> Optional[Union[str, Dict[str,str]]]:
|
||||
"""
|
||||
辅助方法:为指定操作类型获取可以直接用于 StageStepDefinition 的 endpoint_spec_lookup_key。
|
||||
注意:此方法依赖于旧的 _matched_endpoints 机制。如果主要查找机制已改为 get_api_spec_for_operation(lookup_key, global_api_spec, ...),
|
||||
则此方法的用处可能有限,除非有Stage明确需要通过通用 operation_type ("add", "delete") 来获取 method/path key。
|
||||
"""
|
||||
# This method implementation relies on self._matched_endpoints which uses generic operation_type keys
|
||||
# It might conflict or be less useful if the primary way to get spec is via specific lookup_key in get_api_spec_for_operation.
|
||||
# For now, keeping its original logic related to self._matched_endpoints.
|
||||
# Consider if this method needs to be aligned with the new get_api_spec_for_operation behavior or deprecated/refactored.
|
||||
|
||||
api_spec_dict = self._matched_endpoints.get(operation_type) # Uses generic keys like "add"
|
||||
|
||||
if api_spec_dict:
|
||||
method = api_spec_dict.get("method")
|
||||
path = api_spec_dict.get("path")
|
||||
if method and path:
|
||||
return f"{str(method).upper()} {str(path)}"
|
||||
else:
|
||||
self.logger.error(f"'{self.id}': 从 _matched_endpoints 获取的操作 '{operation_type}' 的规格缺少 'method' 或 'path'. 规格: {api_spec_dict}")
|
||||
if required:
|
||||
raise ValueError(f"为 '{operation_type}' 匹配到的API规格无效 (缺少 method/path)。")
|
||||
return None
|
||||
else: # Not found in self._matched_endpoints
|
||||
if required:
|
||||
self.logger.error(f"'{self.id}': 在 _matched_endpoints 中未找到必需的操作类型 '{operation_type}'。")
|
||||
return None
|
||||
|
||||
# --- Stage Lifecycle Hooks ---
|
||||
def before_stage(self, stage_context: Dict[str, Any], global_api_spec: Optional[ParsedAPISpec] = None, api_group_name: Optional[str] = None):
|
||||
"""在阶段所有步骤执行之前调用。"""
|
||||
self.logger.debug(f"Executing before_stage for '{self.id}'")
|
||||
|
||||
def after_stage(self, stage_result: 'ExecutedStageResult', stage_context: Dict[str, Any], global_api_spec: Optional[ParsedAPISpec] = None, api_group_name: Optional[str] = None):
|
||||
"""在阶段所有步骤执行完毕后调用(无论成功、失败或错误)。"""
|
||||
self.logger.debug(f"Executing after_stage for '{self.id}'")
|
||||
|
||||
def before_step(self, step: StageStepDefinition, stage_context: Dict[str, Any], global_api_spec: Optional[ParsedAPISpec] = None, api_group_name: Optional[str] = None):
|
||||
"""在每个步骤执行之前调用。"""
|
||||
self.logger.debug(f"Executing before_step for step '{step.name}' in stage '{self.id}'")
|
||||
|
||||
def after_step(self, step: StageStepDefinition, step_result: 'ExecutedStageStepResult', stage_context: Dict[str, Any], global_api_spec: Optional[ParsedAPISpec] = None, api_group_name: Optional[str] = None):
|
||||
"""在每个步骤执行之后调用。"""
|
||||
self.logger.debug(f"Executing after_step for step '{step.name}' in stage '{self.id}'")
|
||||
|
||||
|
||||
class ExecutedStageStepResult:
|
||||
"""存储单个API测试阶段步骤执行后的结果。"""
|
||||
class Status(str, Enum):
|
||||
PASSED = "通过"
|
||||
FAILED = "失败"
|
||||
ERROR = "执行错误"
|
||||
SKIPPED = "跳过"
|
||||
PENDING = "处理中" # 新增:表示步骤正在等待或预处理
|
||||
|
||||
def __init__(self,
|
||||
step_name: str,
|
||||
status: Status,
|
||||
message: str = "",
|
||||
validation_points: Optional[List[ValidationResult]] = None,
|
||||
duration: float = 0.0,
|
||||
api_call_detail: Optional[APICallDetail] = None, # 记录此步骤的API调用详情
|
||||
extracted_outputs: Optional[Dict[str, Any]] = None,
|
||||
description: Optional[str] = None, # <--- 添加 description
|
||||
lookup_key: Optional[Union[str, Dict[str, str]]] = None, # <--- 添加 lookup_key
|
||||
resolved_endpoint: Optional[str] = None, # <--- 添加 resolved_endpoint
|
||||
request_details: Optional[Dict[str, Any]] = None, # <--- 添加 request_details
|
||||
context_after_step: Optional[Dict[str, Any]] = None # <--- 添加 context_after_step
|
||||
):
|
||||
self.step_name = step_name
|
||||
self.status = status
|
||||
self.message = message
|
||||
self.validation_points = validation_points or []
|
||||
self.duration = duration
|
||||
self.timestamp = time.time()
|
||||
self.api_call_detail = api_call_detail
|
||||
self.extracted_outputs = extracted_outputs or {}
|
||||
self.description = description # <--- 设置属性
|
||||
self.lookup_key = lookup_key # <--- 设置属性
|
||||
self.resolved_endpoint = resolved_endpoint # <--- 设置属性
|
||||
self.request_details = request_details # <--- 设置属性
|
||||
self.context_after_step = context_after_step # <--- 设置属性
|
||||
|
||||
def to_dict(self) -> Dict[str, Any]:
|
||||
vps_details = []
|
||||
if self.validation_points:
|
||||
for vp in self.validation_points:
|
||||
if vp.details and isinstance(vp.details, dict):
|
||||
# 尝试序列化 details,如果包含复杂对象
|
||||
try:
|
||||
# 只取部分关键信息或确保可序列化
|
||||
serializable_details = {"passed": vp.passed, "message": vp.message}
|
||||
if "status_code" in vp.details: serializable_details["status_code"] = vp.details["status_code"]
|
||||
# 不直接序列化整个 response body 以免过大
|
||||
vps_details.append(serializable_details)
|
||||
except TypeError:
|
||||
vps_details.append({"passed": vp.passed, "message": f"{vp.message} (Details not serializable)"})
|
||||
else:
|
||||
vps_details.append({"passed": vp.passed, "message": vp.message})
|
||||
|
||||
|
||||
return {
|
||||
"step_name": self.step_name,
|
||||
"description": self.description, # <--- 添加到输出
|
||||
"lookup_key": self.lookup_key if isinstance(self.lookup_key, str) else str(self.lookup_key), # <--- 添加到输出 (确保字符串化)
|
||||
"resolved_endpoint": self.resolved_endpoint, # <--- 添加到输出
|
||||
"status": self.status.value,
|
||||
"message": self.message or "; ".join([vp.message for vp in self.validation_points if not vp.passed]),
|
||||
"duration_seconds": f"{self.duration:.4f}",
|
||||
"timestamp": time.strftime('%Y-%m-%dT%H:%M:%S%z', time.localtime(self.timestamp)),
|
||||
"validation_points": vps_details,
|
||||
"api_call_curl": self.api_call_detail.curl_command if self.api_call_detail else None,
|
||||
"request_details": self.request_details, # <--- 添加到输出
|
||||
"extracted_outputs": {k: str(v)[:200] + '...' if isinstance(v, (str, bytes)) and len(v) > 200 else v
|
||||
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()} # <--- 添加到输出 (摘要)
|
||||
}
|
||||
|
||||
|
||||
class ExecutedStageResult:
|
||||
"""存储整个API测试阶段执行后的结果。"""
|
||||
class Status(str, Enum):
|
||||
PASSED = "通过"
|
||||
FAILED = "失败"
|
||||
SKIPPED = "跳过" # 如果整个阶段因is_applicable_to_api_group返回False或其他原因被跳过
|
||||
PENDING = "处理中" # 新增状态:表示阶段正在处理中
|
||||
ERROR = "执行错误" # <--- 新增 ERROR 状态
|
||||
|
||||
def __init__(self,
|
||||
stage_id: str,
|
||||
stage_name: str,
|
||||
api_group_metadata: Optional[Dict[str, Any]] = None,
|
||||
description: Optional[str] = None): # <--- 添加 description 参数
|
||||
self.stage_id = stage_id
|
||||
self.stage_name = stage_name
|
||||
self.description = description # <--- 存储 description
|
||||
self.api_group_metadata = api_group_metadata or {}
|
||||
self.overall_status: ExecutedStageResult.Status = ExecutedStageResult.Status.PENDING # 默认为 PENDING
|
||||
self.executed_steps: List[ExecutedStageStepResult] = []
|
||||
self.start_time: float = time.time()
|
||||
self.end_time: Optional[float] = None
|
||||
self.duration: float = 0.0
|
||||
self.message: str = "" # 整个阶段的总结性消息,例如跳过原因或关键失败点
|
||||
self.final_stage_context: Optional[Dict[str, Any]] = None # 最终的 stage_context 内容 (敏感数据需谨慎处理)
|
||||
|
||||
def add_step_result(self, step_result: ExecutedStageStepResult):
|
||||
self.executed_steps.append(step_result)
|
||||
|
||||
def finalize_stage_result(self, final_context: Optional[Dict[str, Any]] = None):
|
||||
self.end_time = time.time()
|
||||
self.duration = self.end_time - self.start_time
|
||||
self.final_stage_context = final_context
|
||||
|
||||
if not self.executed_steps and self.overall_status == ExecutedStageResult.Status.SKIPPED:
|
||||
# 如果没有执行任何步骤且状态是初始的 SKIPPED,则保持
|
||||
if not self.message: self.message = "此阶段没有执行任何步骤,被跳过。"
|
||||
elif any(step.status == ExecutedStageStepResult.Status.ERROR for step in self.executed_steps):
|
||||
self.overall_status = ExecutedStageResult.Status.FAILED # 步骤执行错误导致阶段失败
|
||||
if not self.message: self.message = "一个或多个步骤执行时发生内部错误。"
|
||||
elif any(step.status == ExecutedStageStepResult.Status.FAILED for step in self.executed_steps):
|
||||
self.overall_status = ExecutedStageResult.Status.FAILED
|
||||
if not self.message: self.message = "一个或多个步骤验证失败。"
|
||||
elif all(step.status == ExecutedStageStepResult.Status.SKIPPED for step in self.executed_steps) and self.executed_steps:
|
||||
self.overall_status = ExecutedStageResult.Status.SKIPPED # 所有步骤都跳过了
|
||||
if not self.message: self.message = "所有步骤均被跳过。"
|
||||
elif all(step.status == ExecutedStageStepResult.Status.PASSED or step.status == ExecutedStageStepResult.Status.SKIPPED for step in self.executed_steps) and \
|
||||
any(step.status == ExecutedStageStepResult.Status.PASSED for step in self.executed_steps) :
|
||||
self.overall_status = ExecutedStageResult.Status.PASSED # 至少一个通过,其他是跳过或通过
|
||||
if not self.message: self.message = "阶段执行成功。"
|
||||
else: # 其他情况,例如没有步骤但状态不是 SKIPPED (不应发生),或者混合状态未被明确处理
|
||||
if self.executed_steps: # 如果有步骤,但没有明确成功或失败
|
||||
self.overall_status = ExecutedStageResult.Status.FAILED
|
||||
self.message = self.message or "阶段执行结果不明确,默认标记为失败。"
|
||||
# else: 状态保持为初始的 SKIPPED,message也应该在之前设置了
|
||||
|
||||
def to_dict(self) -> Dict[str, Any]:
|
||||
# 对 final_stage_context 进行处理,避免过大或敏感信息直接输出
|
||||
processed_context = {}
|
||||
if self.final_stage_context:
|
||||
for k, v in self.final_stage_context.items():
|
||||
if isinstance(v, (str, bytes)) and len(v) > 200: # 截断长字符串
|
||||
processed_context[k] = str(v)[:200] + '...'
|
||||
elif isinstance(v, (dict, list)): # 对于字典和列表,只显示键或少量元素
|
||||
processed_context[k] = f"Type: {type(v).__name__}, Keys/Count: {len(v)}"
|
||||
else:
|
||||
processed_context[k] = v
|
||||
|
||||
return {
|
||||
"stage_id": self.stage_id,
|
||||
"stage_name": self.stage_name,
|
||||
"description": self.description, # <--- 添加 description 到输出
|
||||
"api_group_name": self.api_group_metadata.get("name", "N/A"),
|
||||
"overall_status": self.overall_status.value,
|
||||
"duration_seconds": f"{self.duration:.2f}",
|
||||
"start_time": time.strftime('%Y-%m-%dT%H:%M:%S%z', time.localtime(self.start_time)),
|
||||
"end_time": time.strftime('%Y-%m-%dT%H:%M:%S%z', time.localtime(self.end_time)) if self.end_time else None,
|
||||
"message": self.message,
|
||||
"executed_steps_count": len(self.executed_steps),
|
||||
"executed_steps": [step.to_dict() for step in self.executed_steps],
|
||||
# "final_stage_context_summary": processed_context # 可选: 输出处理后的上下文摘要
|
||||
}
|
||||
123
ddms_compliance_suite/stage_registry.py
Normal file
123
ddms_compliance_suite/stage_registry.py
Normal file
@ -0,0 +1,123 @@
|
||||
"""
|
||||
API 测试阶段 (Stage) 注册表模块
|
||||
"""
|
||||
import os
|
||||
import importlib.util
|
||||
import inspect
|
||||
import logging
|
||||
from typing import List, Type, Dict, Optional
|
||||
|
||||
from .stage_framework import BaseAPIStage # 导入新的 BaseAPIStage
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
class StageRegistry:
|
||||
"""
|
||||
负责发现、加载和管理 BaseAPIStage 子类。
|
||||
"""
|
||||
def __init__(self, stages_dir: Optional[str] = None):
|
||||
"""
|
||||
初始化 StageRegistry。
|
||||
Args:
|
||||
stages_dir: 存放自定义 BaseAPIStage Python文件的目录路径。
|
||||
如果为 None 或无效路径,则不会加载任何自定义阶段。
|
||||
"""
|
||||
self.logger = logging.getLogger(__name__)
|
||||
self.stages_dir = stages_dir
|
||||
self._stages: Dict[str, Type[BaseAPIStage]] = {}
|
||||
self._errors: List[str] = []
|
||||
|
||||
if self.stages_dir and os.path.isdir(self.stages_dir):
|
||||
self.logger.info(f"StageRegistry: 开始从目录 '{self.stages_dir}' 加载测试阶段...")
|
||||
self._discover_and_load_stages()
|
||||
if self._errors:
|
||||
for error in self._errors:
|
||||
self.logger.error(f"StageRegistry: 加载阶段时发生错误: {error}")
|
||||
self.logger.info(f"StageRegistry: 加载完成。共加载 {len(self._stages)} 个测试阶段。")
|
||||
elif stages_dir: # 如果提供了stages_dir但不是有效目录
|
||||
self.logger.warning(f"StageRegistry: 提供的阶段目录 '{stages_dir}' 无效或不存在。将不会加载任何自定义阶段。")
|
||||
else: # 如果 stages_dir 未提供
|
||||
self.logger.info("StageRegistry: 未提供阶段目录,将不会加载任何自定义阶段。")
|
||||
|
||||
def _discover_and_load_stages(self):
|
||||
"""发现并加载指定目录下的所有 BaseAPIStage 子类。"""
|
||||
if not self.stages_dir or not os.path.isdir(self.stages_dir):
|
||||
self.logger.warning(f"StageRegistry: 阶段目录 '{self.stages_dir}' 无效或不存在,无法发现阶段。")
|
||||
return
|
||||
|
||||
self.logger.info(f"StageRegistry: 开始从目录 '{self.stages_dir}' 及其子目录发现测试阶段...")
|
||||
found_count = 0
|
||||
# 使用 os.walk 进行递归扫描
|
||||
for root_dir, _, files in os.walk(self.stages_dir):
|
||||
for filename in files:
|
||||
if filename.endswith(".py") and not filename.startswith("__"):
|
||||
module_name = filename[:-3]
|
||||
module_path = os.path.join(root_dir, filename) # 使用 root_dir
|
||||
try:
|
||||
spec = importlib.util.spec_from_file_location(module_name, module_path)
|
||||
if spec and spec.loader:
|
||||
module = importlib.util.module_from_spec(spec)
|
||||
spec.loader.exec_module(module)
|
||||
self.logger.debug(f"StageRegistry: 成功导入模块: {module_name} 从 {module_path}")
|
||||
for name, cls in inspect.getmembers(module, inspect.isclass):
|
||||
if issubclass(cls, BaseAPIStage) and cls is not BaseAPIStage:
|
||||
if not hasattr(cls, 'id') or not cls.id: # 检查id属性是否存在且不为空
|
||||
stage_id = cls.__name__
|
||||
self.logger.warning(f"测试阶段类 '{cls.__name__}' (在模块 '{module_name}' 来自路径 '{module_path}') 未定义有效 'id' 属性,将使用类名 '{stage_id}' 作为其ID。建议为每个阶段设置唯一的 'id'。")
|
||||
else:
|
||||
stage_id = cls.id
|
||||
|
||||
if stage_id in self._stages:
|
||||
self.logger.warning(f"重复的测试阶段ID '{stage_id}' (来自类 '{cls.__name__}' 在模块 '{module_name}' 来自路径 '{module_path}')。之前的定义将被覆盖。请确保阶段ID唯一。")
|
||||
self._stages[stage_id] = cls
|
||||
found_count +=1
|
||||
self.logger.info(f"StageRegistry: 已注册测试阶段: '{stage_id}' (类: {cls.__name__}) 从模块 '{module_name}' (路径: {module_path})")
|
||||
else:
|
||||
self._errors.append(f"无法为文件 '{module_path}' 创建模块规范。")
|
||||
self.logger.error(f"StageRegistry: 无法为文件 '{module_path}' 创建模块规范。")
|
||||
except ImportError as e:
|
||||
error_msg = f"导入模块 '{module_name}' (从 '{module_path}') 失败: {e}"
|
||||
self._errors.append(error_msg)
|
||||
self.logger.error(f"StageRegistry: {error_msg}", exc_info=True)
|
||||
except Exception as e:
|
||||
error_msg = f"加载或检查模块 '{module_name}' (从 '{module_path}') 时发生未知错误: {e}"
|
||||
self._errors.append(error_msg)
|
||||
self.logger.error(f"StageRegistry: {error_msg}", exc_info=True)
|
||||
|
||||
if found_count == 0 and not self._errors:
|
||||
self.logger.info(f"StageRegistry: 在 '{self.stages_dir}' 及其子目录中未找到符合条件的测试阶段文件或类。")
|
||||
elif self._errors:
|
||||
self.logger.warning(f"StageRegistry: 测试阶段发现过程中遇到 {len(self._errors)} 个错误。请检查日志。")
|
||||
|
||||
# 注意:StageRegistry 目前没有像 TestCaseRegistry 那样的排序逻辑,
|
||||
# 如果需要按特定顺序执行 Stages (独立于 API group),未来可以添加。
|
||||
|
||||
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())
|
||||
|
||||
def get_load_errors(self) -> List[str]:
|
||||
"""获取加载过程中发生的错误信息列表。"""
|
||||
return self._errors
|
||||
|
||||
def reload_stages(self):
|
||||
"""
|
||||
重新加载所有测试阶段。会清空当前已加载的阶段和错误记录。
|
||||
"""
|
||||
self.logger.info(f"StageRegistry: 正在从目录 '{self.stages_dir}' 重新加载所有测试阶段...")
|
||||
self._stages.clear()
|
||||
self._errors.clear()
|
||||
if self.stages_dir and os.path.isdir(self.stages_dir):
|
||||
self._discover_and_load_stages()
|
||||
if self._errors:
|
||||
for error in self._errors:
|
||||
self.logger.error(f"StageRegistry (重载时): 加载阶段时发生错误: {error}")
|
||||
self.logger.info(f"StageRegistry: 重新加载完成。共加载 {len(self._stages)} 个测试阶段。")
|
||||
elif self.stages_dir:
|
||||
self.logger.warning(f"StageRegistry (重载时): 提供的阶段目录 '{self.stages_dir}' 无效或不存在。没有加载任何自定义阶段。")
|
||||
else:
|
||||
self.logger.info("StageRegistry (重载时): 未配置阶段目录,没有加载任何自定义阶段。")
|
||||
@ -18,6 +18,14 @@ class ValidationResult:
|
||||
self.message = message # 验证结果的描述信息
|
||||
self.details = details or {} # 其他详细信息,如实际值、期望值等
|
||||
|
||||
def to_dict(self) -> Dict[str, Any]:
|
||||
"""将 ValidationResult 对象转换为字典。"""
|
||||
return {
|
||||
"passed": self.passed,
|
||||
"message": self.message,
|
||||
"details": self.details
|
||||
}
|
||||
|
||||
def __repr__(self):
|
||||
return f"ValidationResult(passed={self.passed}, message='{self.message}')"
|
||||
|
||||
|
||||
@ -21,7 +21,7 @@ from pydantic import BaseModel, Field, create_model, HttpUrl # Added HttpUrl for
|
||||
from pydantic.networks import EmailStr
|
||||
from pydantic.types import Literal # Explicitly import Literal
|
||||
|
||||
from .input_parser.parser import InputParser, YAPIEndpoint, SwaggerEndpoint, ParsedYAPISpec, ParsedSwaggerSpec
|
||||
from .input_parser.parser import InputParser, YAPIEndpoint, SwaggerEndpoint, ParsedYAPISpec, ParsedSwaggerSpec, ParsedAPISpec
|
||||
from .api_caller.caller import APICaller, APIRequest, APIResponse, APICallDetail # Ensure APICallDetail is imported
|
||||
from .json_schema_validator.validator import JSONSchemaValidator
|
||||
from .test_framework_core import ValidationResult, TestSeverity, APIRequestContext, APIResponseContext, BaseAPITestCase
|
||||
@ -29,6 +29,12 @@ from .test_case_registry import TestCaseRegistry
|
||||
from .utils import schema_utils
|
||||
from .utils.common_utils import format_url_with_path_params
|
||||
|
||||
# 新增导入
|
||||
from .stage_framework import BaseAPIStage, ExecutedStageResult, ExecutedStageStepResult, StageStepDefinition
|
||||
from .stage_registry import StageRegistry
|
||||
from .scenario_framework import BaseAPIScenario # ScenarioRegistry was incorrectly imported from here
|
||||
from .scenario_registry import ScenarioRegistry # Corrected import for ScenarioRegistry
|
||||
|
||||
try:
|
||||
from .llm_utils.llm_service import LLMService
|
||||
except ImportError:
|
||||
@ -184,6 +190,14 @@ class TestSummary:
|
||||
self.test_cases_error: int = 0 # 测试用例代码本身出错
|
||||
self.test_cases_skipped_in_endpoint: int = 0 # 测试用例在端点执行中被跳过
|
||||
|
||||
# 新增:场景测试统计 -> 修改为 Stage 统计
|
||||
self.total_stages_defined: int = 0
|
||||
self.total_stages_executed: int = 0
|
||||
self.stages_passed: int = 0
|
||||
self.stages_failed: int = 0
|
||||
self.stages_skipped: int = 0 # 如果 Stage 因为 is_applicable 返回 False 或其他原因被跳过
|
||||
self.detailed_stage_results: List[ExecutedStageResult] = []
|
||||
|
||||
self.start_time = datetime.datetime.now()
|
||||
self.end_time: Optional[datetime.datetime] = None
|
||||
self.detailed_results: List[TestResult] = [] # 将存储新的 TestResult (EndpointExecutionResult) 对象
|
||||
@ -223,6 +237,24 @@ class TestSummary:
|
||||
def set_total_test_cases_applicable(self, count: int):
|
||||
self.total_test_cases_applicable = count
|
||||
|
||||
# 新增:用于场景统计的方法 -> 修改为 Stage 统计方法
|
||||
def add_stage_result(self, result: ExecutedStageResult):
|
||||
self.detailed_stage_results.append(result)
|
||||
# 只有实际执行的 Stage 才会计入 executed 计数器
|
||||
# 如果一个Stage因为is_applicable=False而被跳过,它的executed_steps会是空的
|
||||
if result.overall_status != ExecutedStageResult.Status.SKIPPED or result.executed_steps:
|
||||
self.total_stages_executed += 1
|
||||
|
||||
if result.overall_status == ExecutedStageResult.Status.PASSED:
|
||||
self.stages_passed += 1
|
||||
elif result.overall_status == ExecutedStageResult.Status.FAILED:
|
||||
self.stages_failed += 1
|
||||
elif result.overall_status == ExecutedStageResult.Status.SKIPPED:
|
||||
self.stages_skipped +=1
|
||||
|
||||
def set_total_stages_defined(self, count: int):
|
||||
self.total_stages_defined = count
|
||||
|
||||
def finalize_summary(self):
|
||||
self.end_time = datetime.datetime.now()
|
||||
|
||||
@ -246,7 +278,7 @@ class TestSummary:
|
||||
return (self.test_cases_passed / self.total_test_cases_executed) * 100
|
||||
|
||||
def to_dict(self) -> Dict[str, Any]:
|
||||
return {
|
||||
data = {
|
||||
"summary_metadata": {
|
||||
"start_time": self.start_time.isoformat(),
|
||||
"end_time": self.end_time.isoformat() if self.end_time else None,
|
||||
@ -274,6 +306,17 @@ class TestSummary:
|
||||
},
|
||||
"detailed_results": [result.to_dict() for result in self.detailed_results]
|
||||
}
|
||||
# 新增:将场景测试结果添加到字典 -> 修改为 Stage 测试结果
|
||||
data["stage_stats"] = {
|
||||
"total_defined": self.total_stages_defined,
|
||||
"total_executed": self.total_stages_executed,
|
||||
"passed": self.stages_passed,
|
||||
"failed": self.stages_failed,
|
||||
"skipped": self.stages_skipped,
|
||||
"success_rate_percentage": f"{(self.stages_passed / self.total_stages_executed * 100) if self.total_stages_executed > 0 else 0:.2f}"
|
||||
}
|
||||
data["detailed_stage_results"] = [res.to_dict() for res in self.detailed_stage_results]
|
||||
return data
|
||||
|
||||
def to_json(self, pretty=True) -> str:
|
||||
indent = 2 if pretty else None
|
||||
@ -318,13 +361,45 @@ class TestSummary:
|
||||
for vp in tc_res.validation_points:
|
||||
if not vp.passed:
|
||||
print(f" - 验证点: {vp.message}")
|
||||
|
||||
# 新增:打印场景测试摘要 -> 修改为 Stage 测试摘要
|
||||
if self.total_stages_defined > 0 or self.total_stages_executed > 0:
|
||||
print("\n--- API测试阶段 (Stage) 统计 ---")
|
||||
print(f"定义的API阶段总数: {self.total_stages_defined}")
|
||||
print(f"实际执行的API阶段数: {self.total_stages_executed}")
|
||||
print(f" 通过: {self.stages_passed}")
|
||||
print(f" 失败: {self.stages_failed}")
|
||||
print(f" 跳过: {self.stages_skipped}")
|
||||
if self.total_stages_executed > 0:
|
||||
print(f" 阶段通过率: {(self.stages_passed / self.total_stages_executed * 100):.2f}%")
|
||||
|
||||
failed_stages = [res for res in self.detailed_stage_results if res.overall_status == ExecutedStageResult.Status.FAILED]
|
||||
if failed_stages:
|
||||
print("\n--- 失败的API阶段摘要 ---")
|
||||
for st_res in failed_stages:
|
||||
print(f" 阶段: {st_res.stage_id} ({st_res.stage_name}) - 应用于分组: '{st_res.api_group_metadata.get('name', 'N/A')}' - 状态: {st_res.overall_status.value}")
|
||||
for step_res in st_res.executed_steps:
|
||||
if step_res.status == ExecutedStageStepResult.Status.FAILED:
|
||||
print(f" - 步骤失败: {step_res.step_name} - 消息: {step_res.message}")
|
||||
elif step_res.status == ExecutedStageStepResult.Status.ERROR:
|
||||
print(f" - 步骤错误: {step_res.step_name} - 消息: {step_res.message}")
|
||||
|
||||
class APITestOrchestrator:
|
||||
"""
|
||||
测试编排器,负责加载API定义、发现和执行测试用例、生成报告等。
|
||||
测试编排器,负责协调整个API测试流程。
|
||||
包括:
|
||||
1. 解析API定义 (YAPI, Swagger)
|
||||
2. 加载自定义测试用例 (BaseAPITestCase)
|
||||
3. 执行测试用例并收集结果
|
||||
4. 加载和执行API场景 (BaseAPIScenario) - 已实现
|
||||
5. 加载和执行API测试阶段 (BaseAPIStage) - 新增
|
||||
6. 生成测试报告和API调用详情
|
||||
"""
|
||||
|
||||
def __init__(self, base_url: str,
|
||||
custom_test_cases_dir: Optional[str] = None,
|
||||
scenarios_dir: Optional[str] = None, # Keep existing scenarios_dir
|
||||
stages_dir: Optional[str] = None, # New: Directory for stages
|
||||
llm_api_key: Optional[str] = None,
|
||||
llm_base_url: Optional[str] = None,
|
||||
llm_model_name: Optional[str] = None,
|
||||
@ -332,19 +407,53 @@ class APITestOrchestrator:
|
||||
use_llm_for_path_params: bool = False,
|
||||
use_llm_for_query_params: bool = False,
|
||||
use_llm_for_headers: bool = False,
|
||||
output_dir: Optional[str] = None # output_dir is now optional and not used for saving API call details internally
|
||||
output_dir: Optional[str] = None
|
||||
):
|
||||
self.base_url = base_url.rstrip('/')
|
||||
self.parser = InputParser()
|
||||
self.api_caller = APICaller()
|
||||
self.schema_validator = JSONSchemaValidator()
|
||||
self.test_case_registry = TestCaseRegistry(custom_test_cases_dir)
|
||||
self.logger = logging.getLogger(__name__)
|
||||
# self.output_dir is kept if other parts of the orchestrator might use it,
|
||||
# but it's no longer used by the removed _save_api_call_details
|
||||
self.output_dir_param = output_dir
|
||||
self.parser = InputParser() # Initialize the parser
|
||||
self.api_caller = APICaller() # APICaller does not take base_url in __init__
|
||||
self.json_validator = JSONSchemaValidator()
|
||||
|
||||
self.test_case_registry: Optional[TestCaseRegistry] = None # Initialize as Optional
|
||||
if custom_test_cases_dir:
|
||||
self.logger.info(f"正在从目录加载自定义测试用例: {custom_test_cases_dir}")
|
||||
try:
|
||||
self.test_case_registry = TestCaseRegistry(test_cases_dir=custom_test_cases_dir)
|
||||
self.logger.info(f"加载了 {len(self.test_case_registry.get_all_test_case_classes())} 个自定义测试用例。")
|
||||
except Exception as e:
|
||||
self.logger.error(f"初始化 TestCaseRegistry 或加载测试用例失败: {e}", exc_info=True)
|
||||
self.test_case_registry = None #确保在出错时 registry 为 None
|
||||
else:
|
||||
self.logger.info("未提供自定义测试用例目录,跳过加载自定义测试用例。")
|
||||
|
||||
self.api_call_details_log: List[APICallDetail] = []
|
||||
# Scenario Registry (existing)
|
||||
self.scenario_registry: Optional[ScenarioRegistry] = None # Initialize as Optional
|
||||
if scenarios_dir:
|
||||
self.logger.info(f"正在从目录加载API场景: {scenarios_dir}")
|
||||
self.scenario_registry = ScenarioRegistry()
|
||||
self.scenario_registry.discover_and_load_scenarios(scenarios_dir)
|
||||
self.logger.info(f"加载了 {len(self.scenario_registry.get_all_scenario_classes())} 个API场景。")
|
||||
else:
|
||||
self.logger.info("未提供API场景目录,跳过加载场景测试。")
|
||||
|
||||
# Stage Registry (New)
|
||||
self.stage_registry: Optional[StageRegistry] = None
|
||||
if stages_dir:
|
||||
self.logger.info(f"APITestOrchestrator: 尝试从目录加载API测试阶段: {stages_dir}")
|
||||
try:
|
||||
self.stage_registry = StageRegistry(stages_dir=stages_dir) # Pass stages_dir to constructor
|
||||
# Discovery now happens within StageRegistry.__init__
|
||||
self.logger.info(f"APITestOrchestrator: StageRegistry 初始化完毕。加载的API测试阶段数量: {len(self.stage_registry.get_all_stage_classes())}")
|
||||
load_errors = self.stage_registry.get_load_errors()
|
||||
if load_errors:
|
||||
for err in load_errors:
|
||||
self.logger.error(f"APITestOrchestrator: StageRegistry 加载错误: {err}")
|
||||
except Exception as e:
|
||||
self.logger.error(f"APITestOrchestrator: 初始化 StageRegistry 时发生错误: {e}", exc_info=True)
|
||||
self.stage_registry = None # 确保出错时为 None
|
||||
else:
|
||||
self.logger.info("APITestOrchestrator: 未提供API测试阶段目录,跳过加载阶段测试。")
|
||||
|
||||
# LLM Service Initialization
|
||||
self.llm_service: Optional[LLMService] = None
|
||||
@ -844,7 +953,7 @@ class APITestOrchestrator:
|
||||
test_case_instance = test_case_class(
|
||||
endpoint_spec=endpoint_spec_dict,
|
||||
global_api_spec=global_spec_dict,
|
||||
json_schema_validator=self.schema_validator,
|
||||
json_schema_validator=self.json_validator,
|
||||
llm_service=self.llm_service # Pass the orchestrator's LLM service instance
|
||||
)
|
||||
self.logger.info(f"开始执行测试用例 '{test_case_instance.id}' ({test_case_instance.name}) for endpoint '{endpoint_spec_dict.get('method', 'N/A')} {endpoint_spec_dict.get('path', 'N/A')}'")
|
||||
@ -1429,100 +1538,52 @@ class APITestOrchestrator:
|
||||
def run_tests_from_yapi(self, yapi_file_path: str,
|
||||
categories: Optional[List[str]] = None,
|
||||
custom_test_cases_dir: Optional[str] = None
|
||||
) -> TestSummary:
|
||||
if custom_test_cases_dir and (not self.test_case_registry or self.test_case_registry.test_cases_dir != custom_test_cases_dir):
|
||||
self.logger.info(f"从 run_tests_from_yapi 使用新的目录重新初始化 TestCaseRegistry: {custom_test_cases_dir}")
|
||||
try:
|
||||
self.test_case_registry = TestCaseRegistry(test_cases_dir=custom_test_cases_dir)
|
||||
self.logger.info(f"TestCaseRegistry (re)initialization complete, found {len(self.test_case_registry.get_all_test_case_classes())} test case classes.")
|
||||
except Exception as e:
|
||||
self.logger.error(f"从 run_tests_from_yapi 重新初始化 TestCaseRegistry 失败: {e}", exc_info=True)
|
||||
) -> Tuple[TestSummary, Optional[ParsedAPISpec]]:
|
||||
self.logger.info(f"准备从YAPI文件运行测试用例: {yapi_file_path}")
|
||||
self.api_call_details_log = [] # 为新的测试用例运行重置API调用日志
|
||||
|
||||
self.logger.info(f"从YAPI文件加载API定义: {yapi_file_path}")
|
||||
self.api_call_details_log = [] # Reset for new run
|
||||
|
||||
parsed_yapi = self.parser.parse_yapi_spec(yapi_file_path) # Corrected: self.parser
|
||||
parsed_yapi = self.parser.parse_yapi_spec(yapi_file_path)
|
||||
summary = TestSummary()
|
||||
|
||||
if not parsed_yapi:
|
||||
self.logger.error(f"解析YAPI文件失败: {yapi_file_path}")
|
||||
summary.finalize_summary()
|
||||
# No longer calls _save_api_call_details here
|
||||
return summary
|
||||
summary.finalize_summary() # 即使失败也最终化摘要
|
||||
return summary, None
|
||||
|
||||
endpoints_to_test = parsed_yapi.endpoints
|
||||
if categories:
|
||||
endpoints_to_test = [ep for ep in endpoints_to_test if ep.category_name in categories]
|
||||
|
||||
summary.set_total_endpoints_defined(len(endpoints_to_test))
|
||||
|
||||
total_applicable_tcs = 0
|
||||
if self.test_case_registry:
|
||||
for endpoint_spec in endpoints_to_test:
|
||||
total_applicable_tcs += len(
|
||||
self.test_case_registry.get_applicable_test_cases(
|
||||
endpoint_spec.method.upper(), endpoint_spec.path
|
||||
)
|
||||
)
|
||||
summary.set_total_test_cases_applicable(total_applicable_tcs)
|
||||
|
||||
for endpoint in endpoints_to_test:
|
||||
result = self.run_test_for_endpoint(endpoint, global_api_spec=parsed_yapi)
|
||||
summary.add_endpoint_result(result)
|
||||
|
||||
summary.finalize_summary()
|
||||
# No longer calls _save_api_call_details here
|
||||
summary.print_summary_to_console() # Keep console print
|
||||
return summary
|
||||
# 调用内部执行方法来执行测试用例
|
||||
self._execute_tests_from_parsed_spec(
|
||||
parsed_spec=parsed_yapi,
|
||||
summary=summary,
|
||||
categories=categories,
|
||||
custom_test_cases_dir=custom_test_cases_dir
|
||||
)
|
||||
# finalize_summary 和 print_summary_to_console 将在 run_api_tests.py 中进行
|
||||
return summary, parsed_yapi
|
||||
|
||||
def run_tests_from_swagger(self, swagger_file_path: str,
|
||||
tags: Optional[List[str]] = None,
|
||||
custom_test_cases_dir: Optional[str] = None
|
||||
) -> TestSummary:
|
||||
if custom_test_cases_dir and (not self.test_case_registry or self.test_case_registry.test_cases_dir != custom_test_cases_dir):
|
||||
self.logger.info(f"从 run_tests_from_swagger 使用新的目录重新初始化 TestCaseRegistry: {custom_test_cases_dir}")
|
||||
try:
|
||||
self.test_case_registry = TestCaseRegistry(test_cases_dir=custom_test_cases_dir)
|
||||
self.logger.info(f"TestCaseRegistry (re)initialization complete, found {len(self.test_case_registry.get_all_test_case_classes())} test case classes.")
|
||||
except Exception as e:
|
||||
self.logger.error(f"从 run_tests_from_swagger 重新初始化 TestCaseRegistry 失败: {e}", exc_info=True)
|
||||
) -> Tuple[TestSummary, Optional[ParsedAPISpec]]:
|
||||
self.logger.info(f"准备从Swagger文件运行测试用例: {swagger_file_path}")
|
||||
self.api_call_details_log = [] # 为新的测试用例运行重置API调用日志
|
||||
|
||||
self.logger.info(f"从Swagger文件加载API定义: {swagger_file_path}")
|
||||
self.api_call_details_log = [] # Reset for new run
|
||||
|
||||
parsed_swagger = self.parser.parse_swagger_spec(swagger_file_path) # Corrected: self.parser
|
||||
parsed_swagger = self.parser.parse_swagger_spec(swagger_file_path)
|
||||
summary = TestSummary()
|
||||
|
||||
if not parsed_swagger:
|
||||
self.logger.error(f"解析Swagger文件失败: {swagger_file_path}")
|
||||
summary.finalize_summary()
|
||||
# No longer calls _save_api_call_details here
|
||||
return summary
|
||||
return summary, None
|
||||
|
||||
endpoints_to_test = parsed_swagger.endpoints
|
||||
if tags:
|
||||
endpoints_to_test = [ep for ep in endpoints_to_test if any(tag in ep.tags for tag in tags)]
|
||||
|
||||
summary.set_total_endpoints_defined(len(endpoints_to_test))
|
||||
|
||||
total_applicable_tcs = 0
|
||||
if self.test_case_registry:
|
||||
for endpoint_spec in endpoints_to_test:
|
||||
total_applicable_tcs += len(
|
||||
self.test_case_registry.get_applicable_test_cases(
|
||||
endpoint_spec.method.upper(), endpoint_spec.path
|
||||
)
|
||||
)
|
||||
summary.set_total_test_cases_applicable(total_applicable_tcs)
|
||||
|
||||
for endpoint in endpoints_to_test:
|
||||
result = self.run_test_for_endpoint(endpoint, global_api_spec=parsed_swagger)
|
||||
summary.add_endpoint_result(result)
|
||||
|
||||
summary.finalize_summary()
|
||||
# No longer calls _save_api_call_details here
|
||||
summary.print_summary_to_console() # Keep console print
|
||||
return summary
|
||||
# 调用内部执行方法来执行测试用例
|
||||
self._execute_tests_from_parsed_spec(
|
||||
parsed_spec=parsed_swagger,
|
||||
summary=summary,
|
||||
tags=tags,
|
||||
custom_test_cases_dir=custom_test_cases_dir
|
||||
)
|
||||
# finalize_summary 和 print_summary_to_console 将在 run_api_tests.py 中进行
|
||||
return summary, parsed_swagger
|
||||
|
||||
def _generate_data_from_schema(self, schema: Dict[str, Any],
|
||||
context_name: Optional[str] = None,
|
||||
@ -1886,4 +1947,608 @@ class APITestOrchestrator:
|
||||
self.logger.error(f"[Util] _util_remove_value_at_path 未能在循环内按预期返回。路径: {'.'.join(map(str,path))}")
|
||||
return data_container, None, False
|
||||
|
||||
# --- 新增:场景测试执行相关方法 ---
|
||||
def _resolve_value_from_context_or_literal(self, value_template: Any, stage_context: Dict[str, Any], step_name_for_log: str) -> Any:
|
||||
"""
|
||||
解析一个值,如果它是字符串且符合 {{stage_context.变量}} 格式,则从阶段上下文中取值,否则直接返回值。
|
||||
支持从字典和列表中深入取值,例如 {{stage_context.user.id}} 或 {{stage_context.items[0].name}}。
|
||||
"""
|
||||
if isinstance(value_template, str):
|
||||
match = re.fullmatch(r"\{\{\s*stage_context\.([a-zA-Z0-9_\.\[\]]+)\s*\}\}", value_template)
|
||||
if match:
|
||||
path_expression = match.group(1)
|
||||
self.logger.debug(f"[阶段步骤 '{step_name_for_log}'] 解析上下文路径: '{path_expression}' 来自模板 '{value_template}'")
|
||||
try:
|
||||
current_value = stage_context
|
||||
parts = re.split(r'\.(?![^\[]*\])|\[|\]', path_expression)
|
||||
parts = [p for p in parts if p] # 移除空字符串
|
||||
|
||||
for part in parts:
|
||||
if isinstance(current_value, dict):
|
||||
current_value = current_value[part]
|
||||
elif isinstance(current_value, list) and part.isdigit():
|
||||
current_value = current_value[int(part)]
|
||||
else:
|
||||
raise KeyError(f"路径部分 '{part}' 无法从当前值类型 {type(current_value)} 中解析")
|
||||
self.logger.info(f"[测试阶段步骤 '{step_name_for_log}'] 从上下文解析到值 '{current_value}' (路径: '{path_expression}')")
|
||||
return current_value
|
||||
except (KeyError, IndexError, TypeError) as e:
|
||||
self.logger.error(f"[测试阶段步骤 '{step_name_for_log}'] 从阶段上下文解析路径 '{path_expression}' 失败: {e}", exc_info=True)
|
||||
return None # 或抛出错误,或返回原始模板以提示错误
|
||||
return value_template # 不是有效的占位符,返回原始字符串
|
||||
elif isinstance(value_template, list):
|
||||
return [self._resolve_value_from_context_or_literal(item, stage_context, step_name_for_log) for item in value_template]
|
||||
elif isinstance(value_template, dict):
|
||||
return {k: self._resolve_value_from_context_or_literal(v, stage_context, step_name_for_log) for k, v in value_template.items()} # Corrected scenario_context to stage_context
|
||||
else:
|
||||
return value_template # 其他类型直接返回
|
||||
|
||||
def _extract_outputs_to_context(self, response_content: Any, outputs_map: Dict[str, str], stage_context: Dict[str, Any], step_name_for_log: str):
|
||||
"""
|
||||
根据 outputs_map 从API响应中提取值并存入 stage_context。
|
||||
Args:
|
||||
response_content: API响应的内容 (通常是解析后的JSON字典)。
|
||||
outputs_map: 定义如何提取的字典,例如 {"user_id": "data.id", "token": "header.X-Auth-Token"}。
|
||||
支持 "body.", "header.", "status_code" 作为路径前缀。
|
||||
stage_context: 要更新的阶段上下文。
|
||||
step_name_for_log: 当前步骤名称,用于日志。
|
||||
"""
|
||||
if not outputs_map or response_content is None:
|
||||
return
|
||||
|
||||
for context_var_name, extraction_path in outputs_map.items():
|
||||
self.logger.debug(f"[阶段步骤 '{step_name_for_log}'] 尝试提取 '{extraction_path}' 到上下文变量 '{context_var_name}'")
|
||||
value_to_extract = None
|
||||
try:
|
||||
current_data = response_content
|
||||
path_parts = extraction_path.split('.')
|
||||
|
||||
source_type = path_parts[0].lower()
|
||||
actual_path_parts = path_parts[1:]
|
||||
|
||||
if source_type == "body":
|
||||
target_obj = current_data.get('json_content')
|
||||
elif source_type == "header":
|
||||
target_obj = current_data.get('headers')
|
||||
elif source_type == "status_code":
|
||||
if not actual_path_parts:
|
||||
value_to_extract = current_data.get('status_code')
|
||||
stage_context[context_var_name] = value_to_extract
|
||||
self.logger.info(f"[阶段步骤 '{step_name_for_log}'] 提取到 '{context_var_name}': {value_to_extract}")
|
||||
continue
|
||||
else:
|
||||
self.logger.warning(f"[阶段步骤 '{step_name_for_log}'] status_code 不支持进一步的路径提取: '{extraction_path}'")
|
||||
continue
|
||||
else:
|
||||
self.logger.warning(f"[阶段步骤 '{step_name_for_log}'] 未知的提取源类型 '{source_type}' in path '{extraction_path}'")
|
||||
continue
|
||||
|
||||
if target_obj is None and source_type != "status_code":
|
||||
self.logger.warning(f"[阶段步骤 '{step_name_for_log}'] 提取源 '{source_type}' 为空或不存在。")
|
||||
continue
|
||||
|
||||
temp_val = target_obj
|
||||
for part in actual_path_parts:
|
||||
if isinstance(temp_val, dict):
|
||||
temp_val = temp_val.get(part)
|
||||
elif isinstance(temp_val, list) and part.isdigit():
|
||||
idx = int(part)
|
||||
if 0 <= idx < len(temp_val):
|
||||
temp_val = temp_val[idx]
|
||||
else:
|
||||
temp_val = None; break
|
||||
else:
|
||||
temp_val = None; break
|
||||
if temp_val is None: break
|
||||
|
||||
value_to_extract = temp_val
|
||||
|
||||
if value_to_extract is not None:
|
||||
stage_context[context_var_name] = value_to_extract
|
||||
self.logger.info(f"[阶段步骤 '{step_name_for_log}'] 提取到上下文 '{context_var_name}': {str(value_to_extract)[:100]}...")
|
||||
else:
|
||||
self.logger.warning(f"[阶段步骤 '{step_name_for_log}'] 未能从路径 '{extraction_path}' 提取到值。")
|
||||
except Exception as e:
|
||||
self.logger.error(f"[阶段步骤 '{step_name_for_log}'] 从路径 '{extraction_path}' 提取值时出错: {e}", exc_info=True)
|
||||
|
||||
def execute_single_stage(self,
|
||||
stage_instance: BaseAPIStage,
|
||||
parsed_spec: ParsedAPISpec,
|
||||
api_group_name: Optional[str]
|
||||
) -> ExecutedStageResult:
|
||||
stage_start_time = datetime.datetime.now()
|
||||
stage_context: Dict[str, Any] = {}
|
||||
executed_steps_results: List[ExecutedStageStepResult] = []
|
||||
|
||||
stage_result = ExecutedStageResult(
|
||||
stage_id=stage_instance.id,
|
||||
stage_name=stage_instance.name,
|
||||
description=stage_instance.description,
|
||||
api_group_metadata={"name": api_group_name} if api_group_name else None # <--- 修改参数名并包装为字典
|
||||
# overall_status is set by default in __init__
|
||||
)
|
||||
|
||||
try:
|
||||
self.logger.debug(f"Calling before_stage for stage '{stage_instance.id}'. Context: {stage_context}")
|
||||
# Ensure all parameters passed to before_stage are accepted by its definition
|
||||
stage_instance.before_stage(stage_context=stage_context, global_api_spec=parsed_spec, api_group_name=api_group_name)
|
||||
except Exception as e:
|
||||
self.logger.error(f"Error in before_stage for stage '{stage_instance.id}': {e}", exc_info=True)
|
||||
# stage_result.overall_status = ExecutedStageResult.Status.ERROR # This is already set if ERROR is present in Enum
|
||||
# The following line should correctly set the status if an error occurs in before_stage
|
||||
if hasattr(ExecutedStageResult.Status, 'ERROR'):
|
||||
stage_result.overall_status = ExecutedStageResult.Status.ERROR
|
||||
else: # Fallback if somehow ERROR is still not in the enum (should not happen now)
|
||||
stage_result.overall_status = ExecutedStageResult.Status.FAILED
|
||||
stage_result.message = f"before_stage hook failed: {e}"
|
||||
stage_result.finalize_stage_result(final_context=stage_context) # <--- 更正方法名, 移除多余参数
|
||||
return stage_result
|
||||
|
||||
# Assume PASSED, will be changed on any step failure/error not overridden by continue_on_failure logic
|
||||
# The final status is determined more comprehensively at the end.
|
||||
# stage_result.overall_status = ExecutedStageResult.Status.PASSED
|
||||
|
||||
for step_index, step_definition in enumerate(stage_instance.steps):
|
||||
step_start_time = datetime.datetime.now()
|
||||
step_name = step_definition.name or f"Step {step_index + 1}"
|
||||
step_message = ""
|
||||
step_validation_points: List[ValidationResult] = []
|
||||
|
||||
current_step_result = ExecutedStageStepResult(
|
||||
step_name=step_name,
|
||||
description=getattr(step_definition, 'description', None), # <--- 使用 getattr
|
||||
lookup_key=step_definition.endpoint_spec_lookup_key,
|
||||
status=ExecutedStageStepResult.Status.PENDING
|
||||
# 其他参数 (如 resolved_endpoint, request_details, api_call_details, context_after_step)
|
||||
# 会在步骤执行过程中或之后被填充到 current_step_result 对象上
|
||||
)
|
||||
|
||||
try:
|
||||
self.logger.debug(f"Calling before_step for stage '{stage_instance.id}', step '{step_name}'. Context: {stage_context}")
|
||||
# Corrected: 'step' instead of 'step_definition' to match method signature
|
||||
# Ensure all parameters passed are accepted by before_step definition
|
||||
stage_instance.before_step(step=step_definition, stage_context=stage_context, global_api_spec=parsed_spec, api_group_name=api_group_name)
|
||||
|
||||
self.logger.info(f"Stage '{stage_instance.id}', Step '{step_name}': Looking up endpoint key='{step_definition.endpoint_spec_lookup_key}', group='{api_group_name}'")
|
||||
api_op_spec: Optional[APIOperationSpec] = stage_instance.get_api_spec_for_operation(
|
||||
lookup_key=step_definition.endpoint_spec_lookup_key, # Pass lookup_key by name
|
||||
global_api_spec=parsed_spec, # Pass global_api_spec by name
|
||||
api_group_name=api_group_name # Pass api_group_name by name
|
||||
)
|
||||
|
||||
if not api_op_spec or not api_op_spec.spec:
|
||||
current_step_result.status = ExecutedStageStepResult.Status.ERROR
|
||||
current_step_result.message = f"Could not find API operation for key '{step_definition.endpoint_spec_lookup_key}' and group '{api_group_name}'."
|
||||
self.logger.error(f"Stage '{stage_instance.id}', Step '{step_name}': {current_step_result.message}")
|
||||
else:
|
||||
actual_endpoint_spec_dict = api_op_spec.spec
|
||||
current_step_result.resolved_endpoint = f"{api_op_spec.method.upper()} {api_op_spec.path}" if api_op_spec.method and api_op_spec.path else "Unknown Endpoint"
|
||||
current_step_result.status = ExecutedStageStepResult.Status.PASSED # Assume pass, changed on failure
|
||||
|
||||
self.logger.debug(f"Stage '{stage_instance.id}', Step '{step_name}': Preparing request data for resolved endpoint: {current_step_result.resolved_endpoint}")
|
||||
base_request_context: APIRequestContext = self._prepare_initial_request_data(actual_endpoint_spec_dict, None)
|
||||
|
||||
final_path_params = copy.deepcopy(base_request_context.path_params)
|
||||
final_query_params = copy.deepcopy(base_request_context.query_params)
|
||||
final_headers = copy.deepcopy(base_request_context.headers)
|
||||
final_body = base_request_context.body
|
||||
|
||||
# Default Content-Type for JSON body if body is overridden and Content-Type not in headers
|
||||
if "body" in step_definition.request_overrides:
|
||||
temp_resolved_body_val = self._resolve_value_from_context_or_literal(
|
||||
step_definition.request_overrides["body"], stage_context, step_name
|
||||
)
|
||||
# Check if Content-Type is already being set by header overrides
|
||||
is_content_type_in_header_override = False
|
||||
if "headers" in step_definition.request_overrides:
|
||||
resolved_header_overrides = self._resolve_value_from_context_or_literal(
|
||||
step_definition.request_overrides["headers"], stage_context, step_name
|
||||
)
|
||||
if isinstance(resolved_header_overrides, dict):
|
||||
for h_key in resolved_header_overrides.keys():
|
||||
if h_key.lower() == 'content-type':
|
||||
is_content_type_in_header_override = True; break
|
||||
|
||||
# Check Content-Type in base_request_context.headers (after _prepare_initial_request_data)
|
||||
is_content_type_in_final_headers = any(h_key.lower() == 'content-type' for h_key in final_headers.keys())
|
||||
|
||||
if isinstance(temp_resolved_body_val, (dict, list)) and not is_content_type_in_header_override and not is_content_type_in_final_headers:
|
||||
final_headers['Content-Type'] = 'application/json'
|
||||
self.logger.debug(f"Stage '{stage_instance.id}', Step '{step_name}': Defaulted Content-Type to application/json for overridden body.")
|
||||
|
||||
|
||||
for key, value_template in step_definition.request_overrides.items():
|
||||
resolved_value = self._resolve_value_from_context_or_literal(value_template, stage_context, step_name)
|
||||
if key == "path_params":
|
||||
if isinstance(resolved_value, dict): final_path_params.update(resolved_value)
|
||||
else: self.logger.warning(f"Step '{step_name}': path_params override was not a dict (type: {type(resolved_value)}).")
|
||||
elif key == "query_params":
|
||||
if isinstance(resolved_value, dict): final_query_params.update(resolved_value)
|
||||
else: self.logger.warning(f"Step '{step_name}': query_params override was not a dict (type: {type(resolved_value)}).")
|
||||
elif key == "headers":
|
||||
if isinstance(resolved_value, dict): final_headers.update(resolved_value) # Allows case-sensitive overrides if needed by server
|
||||
else: self.logger.warning(f"Step '{step_name}': headers override was not a dict (type: {type(resolved_value)}).")
|
||||
elif key == "body":
|
||||
final_body = resolved_value
|
||||
else:
|
||||
self.logger.warning(f"Stage '{stage_instance.id}', Step '{step_name}': Unknown request override key '{key}'.")
|
||||
|
||||
# 构建完整的请求 URL
|
||||
full_request_url = self._format_url_with_path_params(
|
||||
path_template=api_op_spec.path,
|
||||
path_params=final_path_params
|
||||
)
|
||||
self.logger.debug(f"Stage '{stage_instance.id}', Step '{step_name}': Constructed full_request_url: {full_request_url}")
|
||||
|
||||
api_request = APIRequest(
|
||||
method=api_op_spec.method,
|
||||
url=full_request_url, # <--- 使用构建好的完整 URL
|
||||
params=final_query_params, # <--- query_params 对应 APIRequest 中的 params
|
||||
headers=final_headers,
|
||||
body=final_body # APIRequest 会通过 model_post_init 将 body 赋给 json_data
|
||||
)
|
||||
current_step_result.request_details = api_request.model_dump() # Use model_dump for Pydantic v2
|
||||
|
||||
self.logger.info(f"Stage '{stage_instance.id}', Step '{step_name}': Executing API call {api_request.method} {api_request.url}") # Log the full URL
|
||||
api_response, api_call_detail = self.api_caller.call_api(api_request) # APICaller.call_api expects APIRequest
|
||||
current_step_result.api_call_details = api_call_detail.model_dump() # Use model_dump for Pydantic v2
|
||||
|
||||
self.logger.debug(f"Stage '{stage_instance.id}', Step '{step_name}': Validating response. Status: {api_response.status_code}")
|
||||
|
||||
# Create APIResponseContext with the *actual* request sent
|
||||
actual_request_context = APIRequestContext(
|
||||
method=api_request.method,
|
||||
url=str(api_request.url), # Pass the full URL string
|
||||
path_params=final_path_params, # Keep for context, though URL is final
|
||||
query_params=api_request.params, # query_params from APIRequest
|
||||
headers=api_request.headers,
|
||||
body=api_request.json_data, # body from APIRequest (after potential alias)
|
||||
endpoint_spec=actual_endpoint_spec_dict # Ensure endpoint_spec is passed
|
||||
# Removed full_url=str(api_call_detail.request_url) as it's redundant with 'url'
|
||||
)
|
||||
response_context = APIResponseContext(
|
||||
request_context=actual_request_context,
|
||||
status_code=api_response.status_code,
|
||||
headers=api_response.headers,
|
||||
json_content=api_response.json_content,
|
||||
text_content=api_response.content.decode('utf-8', errors='replace') if api_response.content else None,
|
||||
elapsed_time=api_response.elapsed_time,
|
||||
original_response= getattr(api_response, 'raw_response', None)
|
||||
# Removed endpoint_spec=actual_endpoint_spec_dict as it's part of actual_request_context
|
||||
)
|
||||
|
||||
if step_definition.expected_status_codes and api_response.status_code not in step_definition.expected_status_codes: # Check against list
|
||||
msg = f"Expected status code in {step_definition.expected_status_codes}, got {api_response.status_code}."
|
||||
step_validation_points.append(ValidationResult(passed=False, message=msg, details={"expected": step_definition.expected_status_codes, "actual": api_response.status_code}))
|
||||
current_step_result.status = ExecutedStageStepResult.Status.FAILED
|
||||
step_message += msg + " "
|
||||
self.logger.warning(f"Stage '{stage_instance.id}', Step '{step_name}': {msg}")
|
||||
elif step_definition.expected_status_codes and api_response.status_code in step_definition.expected_status_codes:
|
||||
step_validation_points.append(ValidationResult(passed=True, message=f"Status code matched ({api_response.status_code})."))
|
||||
|
||||
for i, assertion_func in enumerate(step_definition.response_assertions): # <--- Corrected: custom_assertions to response_assertions
|
||||
assertion_name = getattr(assertion_func, '__name__', f"custom_assertion_{i+1}")
|
||||
try:
|
||||
self.logger.debug(f"Stage '{stage_instance.id}', Step '{step_name}': Running assertion '{assertion_name}'")
|
||||
val_res = assertion_func(response_context, stage_context)
|
||||
step_validation_points.append(val_res)
|
||||
if not val_res.passed:
|
||||
current_step_result.status = ExecutedStageStepResult.Status.FAILED
|
||||
step_message += f"Assertion '{assertion_name}' failed: {val_res.message}. "
|
||||
self.logger.warning(f"Stage '{stage_instance.id}', Step '{step_name}': Assertion '{assertion_name}' failed: {val_res.message}")
|
||||
except Exception as assert_exc:
|
||||
current_step_result.status = ExecutedStageStepResult.Status.ERROR
|
||||
errMsg = f"Assertion '{assertion_name}' execution error: {assert_exc}"
|
||||
step_message += errMsg + " "
|
||||
step_validation_points.append(ValidationResult(passed=False, message=errMsg, details={"error": str(assert_exc)}))
|
||||
self.logger.error(f"Stage '{stage_instance.id}', Step '{step_name}': {errMsg}", exc_info=True)
|
||||
|
||||
if current_step_result.status != ExecutedStageStepResult.Status.ERROR:
|
||||
self.logger.debug(f"Stage '{stage_instance.id}', Step '{step_name}': Extracting outputs. Map: {step_definition.outputs_to_context}")
|
||||
response_data_for_extraction = {
|
||||
"json_content": api_response.json_content,
|
||||
"headers": api_response.headers,
|
||||
"status_code": api_response.status_code
|
||||
}
|
||||
self._extract_outputs_to_context(
|
||||
response_data_for_extraction, step_definition.outputs_to_context,
|
||||
stage_context, f"Stage '{stage_instance.id}', Step '{step_name}'"
|
||||
)
|
||||
current_step_result.context_after_step = copy.deepcopy(stage_context)
|
||||
|
||||
except Exception as step_exec_exc:
|
||||
current_step_result.status = ExecutedStageStepResult.Status.ERROR
|
||||
current_step_result.message = (current_step_result.message + f" | Unexpected error during step execution: {step_exec_exc}").strip()
|
||||
self.logger.error(f"Stage '{stage_instance.id}', Step '{step_name}': {current_step_result.message}", exc_info=True)
|
||||
|
||||
finally:
|
||||
current_step_result.duration_seconds = (datetime.datetime.now() - step_start_time).total_seconds()
|
||||
current_step_result.message = (current_step_result.message + " " + step_message).strip()
|
||||
current_step_result.validation_points = [vp.to_dict() for vp in step_validation_points]
|
||||
|
||||
try:
|
||||
self.logger.debug(f"Calling after_step for stage '{stage_instance.id}', step '{step_name}'.")
|
||||
# Corrected: Pass arguments by keyword to match signature and avoid positional errors
|
||||
stage_instance.after_step(
|
||||
step=step_definition,
|
||||
step_result=current_step_result,
|
||||
stage_context=stage_context,
|
||||
global_api_spec=parsed_spec,
|
||||
api_group_name=api_group_name
|
||||
)
|
||||
except Exception as e_as:
|
||||
self.logger.error(f"Error in after_step for stage '{stage_instance.id}', step '{step_name}': {e_as}", exc_info=True)
|
||||
if current_step_result.status == ExecutedStageStepResult.Status.PASSED:
|
||||
current_step_result.status = ExecutedStageStepResult.Status.ERROR
|
||||
current_step_result.message = (current_step_result.message + f" | after_step hook failed: {e_as}").strip()
|
||||
elif current_step_result.message: current_step_result.message += f" | after_step hook failed: {e_as}"
|
||||
else: current_step_result.message = f"after_step hook failed: {e_as}"
|
||||
|
||||
executed_steps_results.append(current_step_result)
|
||||
|
||||
if current_step_result.status != ExecutedStageStepResult.Status.PASSED:
|
||||
if not stage_instance.continue_on_failure:
|
||||
self.logger.warning(f"Stage '{stage_instance.id}', Step '{step_name}' status {current_step_result.status.value}, continue_on_failure=False. Aborting stage.")
|
||||
# Update stage_result's overall status and message pre-emptively if aborting
|
||||
if current_step_result.status == ExecutedStageStepResult.Status.ERROR:
|
||||
stage_result.overall_status = ExecutedStageResult.Status.ERROR
|
||||
stage_result.message = stage_result.message or f"Stage aborted due to error in step '{step_name}'."
|
||||
elif current_step_result.status == ExecutedStageStepResult.Status.FAILED and stage_result.overall_status != ExecutedStageResult.Status.ERROR:
|
||||
stage_result.overall_status = ExecutedStageResult.Status.FAILED
|
||||
stage_result.message = stage_result.message or f"Stage aborted due to failure in step '{step_name}'."
|
||||
break
|
||||
|
||||
# Determine final stage status
|
||||
if stage_result.overall_status == ExecutedStageResult.Status.PENDING: # If not set by before_stage error or early abort
|
||||
if not executed_steps_results and stage_instance.steps: # Steps defined but loop didn't run/finish (e.g. continue_on_failure issue)
|
||||
stage_result.overall_status = ExecutedStageResult.Status.SKIPPED
|
||||
stage_result.message = stage_result.message or "No steps were effectively executed."
|
||||
elif not stage_instance.steps: # No steps defined for the stage
|
||||
stage_result.overall_status = ExecutedStageResult.Status.PASSED # Considered PASSED if before_stage was OK
|
||||
stage_result.message = stage_result.message or "Stage has no steps."
|
||||
elif any(s.status == ExecutedStageStepResult.Status.ERROR for s in executed_steps_results):
|
||||
stage_result.overall_status = ExecutedStageResult.Status.ERROR
|
||||
stage_result.message = stage_result.message or "One or more steps encountered an error."
|
||||
elif any(s.status == ExecutedStageStepResult.Status.FAILED for s in executed_steps_results):
|
||||
stage_result.overall_status = ExecutedStageResult.Status.FAILED
|
||||
stage_result.message = stage_result.message or "One or more steps failed."
|
||||
elif all(s.status == ExecutedStageStepResult.Status.PASSED for s in executed_steps_results if executed_steps_results): # all steps passed
|
||||
stage_result.overall_status = ExecutedStageResult.Status.PASSED
|
||||
elif all(s.status == ExecutedStageStepResult.Status.SKIPPED for s in executed_steps_results if executed_steps_results): # all steps skipped
|
||||
stage_result.overall_status = ExecutedStageResult.Status.SKIPPED
|
||||
stage_result.message = stage_result.message or "All steps were skipped."
|
||||
else: # Mix of PASSED, SKIPPED, etc., but no FAILED or ERROR that wasn't handled by continue_on_failure
|
||||
# This case implies successful completion if no explicit FAILED/ERROR states propagated.
|
||||
# If there are executed steps, and none failed or errored, it's a pass.
|
||||
# If all executed steps passed or were skipped, and at least one passed: PASSED
|
||||
# If all executed steps were skipped: SKIPPED
|
||||
has_passed_step = any(s.status == ExecutedStageStepResult.Status.PASSED for s in executed_steps_results)
|
||||
if has_passed_step:
|
||||
stage_result.overall_status = ExecutedStageResult.Status.PASSED
|
||||
else: # No errors, no failures, no passes, implies all skipped or pending (which shouldn't happen for completed steps)
|
||||
stage_result.overall_status = ExecutedStageResult.Status.SKIPPED
|
||||
stage_result.message = stage_result.message or "Steps completed without explicit pass, fail, or error."
|
||||
|
||||
|
||||
try:
|
||||
self.logger.debug(f"Calling after_stage for stage '{stage_instance.id}'.")
|
||||
stage_instance.after_stage(stage_result=stage_result, stage_context=stage_context, global_api_spec=parsed_spec, api_group_name=api_group_name)
|
||||
except Exception as e_asg:
|
||||
self.logger.error(f"Error in after_stage for stage '{stage_instance.id}': {e_asg}", exc_info=True)
|
||||
if stage_result.overall_status not in [ExecutedStageResult.Status.ERROR]: # Don't override a more severe status
|
||||
original_status_msg = f"(Original status: {stage_result.overall_status.value})" if stage_result.overall_status != ExecutedStageResult.Status.PASSED else ""
|
||||
stage_result.overall_status = ExecutedStageResult.Status.ERROR
|
||||
current_msg = stage_result.message if stage_result.message else ""
|
||||
stage_result.message = f"{current_msg} after_stage hook failed: {e_asg} {original_status_msg}".strip()
|
||||
elif stage_result.message: stage_result.message += f" | after_stage hook failed: {e_asg}"
|
||||
else: stage_result.message = f"after_stage hook failed: {e_asg}"
|
||||
|
||||
stage_result.finalize_stage_result(final_context=stage_context)
|
||||
self.logger.info(f"Stage '{stage_instance.id}' execution finished. API Group: '{api_group_name}', Final Status: {stage_result.overall_status.value}, Duration: {stage_result.duration:.2f}s") # Corrected duration_seconds to duration
|
||||
return stage_result
|
||||
|
||||
def run_stages_from_spec(self,
|
||||
parsed_spec: ParsedAPISpec,
|
||||
summary: TestSummary):
|
||||
self.logger.info("Starting API Test Stage execution...")
|
||||
if not self.stage_registry or not self.stage_registry.get_all_stage_classes():
|
||||
self.logger.info("No API Test Stages loaded. Skipping stage execution.")
|
||||
return
|
||||
|
||||
stage_classes = self.stage_registry.get_all_stage_classes()
|
||||
summary.set_total_stages_defined(len(stage_classes))
|
||||
|
||||
api_groups: List[Optional[str]] = []
|
||||
if isinstance(parsed_spec, ParsedYAPISpec):
|
||||
# Use parsed_spec.categories which are List[Dict[str, str]] from YAPIInput
|
||||
if parsed_spec.categories:
|
||||
api_groups.extend([cat.get('name') for cat in parsed_spec.categories if cat.get('name')])
|
||||
# If categories list exists but all are unnamed or empty, or if no categories attribute
|
||||
if not api_groups and (not hasattr(parsed_spec, 'categories') or parsed_spec.categories is not None):
|
||||
self.logger.info("YAPI spec: No named categories found or categories attribute missing/empty. Applying stages to the whole spec (api_group_name=None).")
|
||||
api_groups.append(None)
|
||||
elif isinstance(parsed_spec, ParsedSwaggerSpec):
|
||||
# Use parsed_spec.tags which are List[Dict[str, str]]
|
||||
if parsed_spec.tags:
|
||||
api_groups.extend([tag.get('name') for tag in parsed_spec.tags if tag.get('name')])
|
||||
if not api_groups and (not hasattr(parsed_spec, 'tags') or parsed_spec.tags is not None):
|
||||
self.logger.info("Swagger spec: No named tags found or tags attribute missing/empty. Applying stages to the whole spec (api_group_name=None).")
|
||||
api_groups.append(None)
|
||||
|
||||
if not api_groups: # Default for other spec types or if above logic resulted in empty list
|
||||
self.logger.info("No specific API groups (categories/tags) identified. Applying stages to the whole spec (api_group_name=None).")
|
||||
api_groups.append(None)
|
||||
|
||||
self.logger.info(f"Evaluating test stages against {len(api_groups)} API group(s): {api_groups}")
|
||||
|
||||
total_stages_considered_for_execution = 0
|
||||
|
||||
for stage_class in stage_classes:
|
||||
# For template instance, provide default/empty metadata and api lists
|
||||
# as it's mainly used for accessing static-like properties before group-specific execution.
|
||||
default_group_meta = {"name": "Template Group", "description": "Used for stage template loading"}
|
||||
stage_instance_template = stage_class(
|
||||
api_group_metadata=default_group_meta,
|
||||
apis_in_group=[],
|
||||
llm_service=self.llm_service,
|
||||
global_api_spec=parsed_spec # Template might still need global spec for some pre-checks
|
||||
)
|
||||
self.logger.info(f"Processing Test Stage definition: ID='{stage_instance_template.id}', Name='{stage_instance_template.name}'")
|
||||
|
||||
was_applicable_and_executed_at_least_once = False
|
||||
|
||||
for api_group_name in api_groups:
|
||||
# Prepare api_group_metadata and apis_in_group for the current group
|
||||
current_group_metadata: Dict[str, Any] = {}
|
||||
current_apis_in_group_dicts: List[Dict[str, Any]] = []
|
||||
|
||||
if api_group_name:
|
||||
if isinstance(parsed_spec, ParsedYAPISpec) and parsed_spec.spec and 'categories' in parsed_spec.spec:
|
||||
category_details_list = parsed_spec.spec.get('categories', [])
|
||||
category_detail = next((cat for cat in category_details_list if cat.get('name') == api_group_name), None)
|
||||
if category_detail:
|
||||
current_group_metadata = {"name": api_group_name,
|
||||
"description": category_detail.get("desc"),
|
||||
"id": category_detail.get("_id")}
|
||||
category_id_to_match = category_detail.get("_id")
|
||||
# Filter endpoints for this category by id
|
||||
current_apis_in_group_dicts = [
|
||||
ep.to_dict() for ep in parsed_spec.endpoints
|
||||
if hasattr(ep, 'category_id') and ep.category_id == category_id_to_match
|
||||
]
|
||||
else:
|
||||
self.logger.warning(f"Could not find details for YAPI category: {api_group_name} in parsed_spec.spec.categories. API list for this group might be empty.")
|
||||
current_group_metadata = {"name": api_group_name, "description": "Details not found in spec top-level categories"}
|
||||
current_apis_in_group_dicts = [] # Or could attempt to filter by name if IDs are unreliable
|
||||
|
||||
elif isinstance(parsed_spec, ParsedSwaggerSpec):
|
||||
# For Swagger, tags on operations are primary; global tags are for definition.
|
||||
current_group_metadata = {"name": api_group_name}
|
||||
if parsed_spec.spec and 'tags' in parsed_spec.spec:
|
||||
tag_detail = next((tag for tag in parsed_spec.spec.get('tags', []) if tag.get('name') == api_group_name), None)
|
||||
if tag_detail:
|
||||
current_group_metadata["description"] = tag_detail.get("description")
|
||||
else: # Tag name exists (from api_groups list) but not in the top-level tags definitions
|
||||
current_group_metadata["description"] = "Tag defined on operation, not in global tags list."
|
||||
|
||||
# Filter endpoints for this tag
|
||||
current_apis_in_group_dicts = [
|
||||
ep.to_dict() for ep in parsed_spec.endpoints
|
||||
if hasattr(ep, 'tags') and isinstance(ep.tags, list) and api_group_name in ep.tags
|
||||
]
|
||||
else:
|
||||
self.logger.warning(f"API group '{api_group_name}' provided, but cannot determine group details or filter APIs for spec type {type(parsed_spec)}.")
|
||||
current_group_metadata = {"name": api_group_name, "description": "Unknown group type or spec structure"}
|
||||
current_apis_in_group_dicts = [ep.to_dict() for ep in parsed_spec.endpoints]
|
||||
|
||||
else: # api_group_name is None (global scope)
|
||||
current_group_metadata = {"name": "Global (All APIs)", "description": "Applies to all APIs in the spec"}
|
||||
current_apis_in_group_dicts = [ep.to_dict() for ep in parsed_spec.endpoints]
|
||||
|
||||
# Instantiate the stage with the prepared context for the current API group
|
||||
stage_instance = stage_class(
|
||||
api_group_metadata=current_group_metadata,
|
||||
apis_in_group=current_apis_in_group_dicts,
|
||||
llm_service=self.llm_service,
|
||||
global_api_spec=parsed_spec
|
||||
)
|
||||
total_stages_considered_for_execution += 1
|
||||
|
||||
try:
|
||||
self.logger.debug(f"Checking applicability of stage '{stage_instance.id}' for API group '{api_group_name}'...")
|
||||
applicable = stage_instance.is_applicable_to_api_group(
|
||||
api_group_name=api_group_name,
|
||||
global_api_spec=parsed_spec
|
||||
)
|
||||
except Exception as e:
|
||||
self.logger.error(f"Error checking applicability of stage '{stage_instance.id}' for group '{api_group_name}': {e}", exc_info=True)
|
||||
error_result = ExecutedStageResult(
|
||||
stage_id=stage_instance.id, stage_name=stage_instance.name, api_group=api_group_name,
|
||||
overall_status=ExecutedStageResult.Status.ERROR, message=f"Error during applicability check: {e}"
|
||||
)
|
||||
error_result.finalize_result(datetime.datetime.now(), [], {})
|
||||
summary.add_stage_result(error_result)
|
||||
was_applicable_and_executed_at_least_once = True # Considered an attempt
|
||||
continue
|
||||
|
||||
if applicable:
|
||||
self.logger.info(f"Test Stage '{stage_instance.id}' is APPLICABLE to API group '{api_group_name}'. Executing...")
|
||||
stage_execution_result = self.execute_single_stage(stage_instance, parsed_spec, api_group_name)
|
||||
summary.add_stage_result(stage_execution_result)
|
||||
was_applicable_and_executed_at_least_once = True
|
||||
else:
|
||||
self.logger.info(f"Test Stage '{stage_instance.id}' is NOT APPLICABLE to API group '{api_group_name}'. Skipping for this group.")
|
||||
|
||||
if not was_applicable_and_executed_at_least_once and stage_instance_template.fail_if_not_applicable_to_any_group:
|
||||
self.logger.warning(f"Test Stage '{stage_instance_template.id}' was not applicable to any API group and 'fail_if_not_applicable_to_any_group' is True.")
|
||||
failure_result = ExecutedStageResult(
|
||||
stage_id=stage_instance_template.id, stage_name=stage_instance_template.name, api_group=None,
|
||||
overall_status=ExecutedStageResult.Status.FAILED,
|
||||
message=f"Stage marked as 'must apply' but was not applicable to any of the evaluated groups: {api_groups}."
|
||||
)
|
||||
failure_result.finalize_result(datetime.datetime.now(), [], {})
|
||||
summary.add_stage_result(failure_result)
|
||||
|
||||
self.logger.info(f"API Test Stage execution processed. Considered {total_stages_considered_for_execution} (stage_definition x api_group) combinations.")
|
||||
|
||||
def _execute_tests_from_parsed_spec(self,
|
||||
parsed_spec: ParsedAPISpec,
|
||||
summary: TestSummary,
|
||||
categories: Optional[List[str]] = None,
|
||||
tags: Optional[List[str]] = None,
|
||||
custom_test_cases_dir: Optional[str] = None
|
||||
) -> TestSummary:
|
||||
"""基于已解析的API规范对象执行测试用例。"""
|
||||
# Restore the original start of the method body, the rest of the method should be intact from before.
|
||||
if custom_test_cases_dir and (not self.test_case_registry or not hasattr(self.test_case_registry, 'test_cases_dir') or self.test_case_registry.test_cases_dir != custom_test_cases_dir):
|
||||
self.logger.info(f"Re-initializing TestCaseRegistry from _execute_tests_from_parsed_spec with new directory: {custom_test_cases_dir}")
|
||||
try:
|
||||
# Assuming TestCaseRegistry can be re-initialized or its directory updated.
|
||||
# If TestCaseRegistry is loaded in __init__, this might need adjustment
|
||||
# For now, let's assume direct re-init is possible if dir changes.
|
||||
self.test_case_registry = TestCaseRegistry()
|
||||
self.test_case_registry.discover_and_load_test_cases(custom_test_cases_dir)
|
||||
self.logger.info(f"TestCaseRegistry (re)initialized, found {len(self.test_case_registry.get_all_test_case_classes())} test case classes.")
|
||||
except Exception as e:
|
||||
self.logger.error(f"Failed to re-initialize TestCaseRegistry from _execute_tests_from_parsed_spec: {e}", exc_info=True)
|
||||
# summary.finalize_summary() # Finalize might be premature here
|
||||
return summary # Early exit if registry fails
|
||||
|
||||
endpoints_to_test: List[Union[YAPIEndpoint, SwaggerEndpoint]] = []
|
||||
if isinstance(parsed_spec, ParsedYAPISpec):
|
||||
endpoints_to_test = parsed_spec.endpoints
|
||||
if categories:
|
||||
# Ensure YAPIEndpoint has 'category_name' if this filter is used.
|
||||
endpoints_to_test = [ep for ep in endpoints_to_test if hasattr(ep, 'category_name') and ep.category_name in categories]
|
||||
elif isinstance(parsed_spec, ParsedSwaggerSpec):
|
||||
endpoints_to_test = parsed_spec.endpoints
|
||||
if tags:
|
||||
# Ensure SwaggerEndpoint has 'tags' attribute for this filter.
|
||||
endpoints_to_test = [ep for ep in endpoints_to_test if hasattr(ep, 'tags') and isinstance(ep.tags, list) and any(tag in ep.tags for tag in tags)]
|
||||
else:
|
||||
self.logger.warning(f"Unknown parsed_spec type: {type(parsed_spec)}. Cannot filter endpoints.")
|
||||
# summary.finalize_summary() # Finalize might be premature
|
||||
return summary
|
||||
|
||||
current_total_defined = summary.total_endpoints_defined
|
||||
summary.set_total_endpoints_defined(current_total_defined + len(endpoints_to_test))
|
||||
|
||||
total_applicable_tcs_for_this_run = 0
|
||||
if self.test_case_registry:
|
||||
for endpoint_spec_obj in endpoints_to_test:
|
||||
total_applicable_tcs_for_this_run += len(
|
||||
self.test_case_registry.get_applicable_test_cases(
|
||||
endpoint_spec_obj.method.upper(), endpoint_spec_obj.path
|
||||
)
|
||||
)
|
||||
current_total_applicable = summary.total_test_cases_applicable
|
||||
summary.set_total_test_cases_applicable(current_total_applicable + total_applicable_tcs_for_this_run)
|
||||
|
||||
for endpoint in endpoints_to_test:
|
||||
# global_api_spec 应该是包含完整定义的 ParsedYAPISpec/ParsedSwaggerSpec 对象
|
||||
# 而不是其内部的 .spec 字典,因为 _execute_single_test_case 需要这个对象
|
||||
result = self.run_test_for_endpoint(endpoint, global_api_spec=parsed_spec)
|
||||
summary.add_endpoint_result(result)
|
||||
|
||||
return summary
|
||||
|
||||
|
||||
219
flask_app.py
Normal file
219
flask_app.py
Normal file
@ -0,0 +1,219 @@
|
||||
import os
|
||||
import sys
|
||||
import json
|
||||
import logging
|
||||
import argparse
|
||||
import traceback # 用于更详细的错误日志
|
||||
from pathlib import Path
|
||||
from flask import Flask, request, jsonify, send_from_directory
|
||||
from flask_cors import CORS # 用于处理跨域请求
|
||||
|
||||
# 将ddms_compliance_suite的父目录添加到sys.path
|
||||
# 假设flask_app.py与ddms_compliance_suite目录在同一级别,或者ddms_compliance_suite在其PYTHONPATH中
|
||||
# 如果 ddms_compliance_suite 是一个已安装的包,则不需要这个
|
||||
# current_dir = os.path.dirname(os.path.abspath(__file__))
|
||||
# project_root = os.path.dirname(current_dir) # 假设项目根目录是上一级
|
||||
# sys.path.insert(0, project_root)
|
||||
# 或者更具体地添加包含ddms_compliance_suite的目录
|
||||
# sys.path.insert(0, os.path.join(project_root, 'ddms_compliance_suite'))
|
||||
|
||||
from ddms_compliance_suite.test_orchestrator import APITestOrchestrator, TestSummary
|
||||
from ddms_compliance_suite.input_parser.parser import InputParser, ParsedYAPISpec, ParsedSwaggerSpec
|
||||
# 从 run_api_tests.py 导入辅助函数 (如果它们被重构为可导入的)
|
||||
# 为了简单起见,我们可能会直接在 flask_app.py 中重新实现一些逻辑或直接调用Orchestrator
|
||||
|
||||
app = Flask(__name__, static_folder='static', static_url_path='')
|
||||
CORS(app) # 允许所有来源的跨域请求,生产环境中应配置更严格的规则
|
||||
|
||||
# 配置日志
|
||||
logging.basicConfig(
|
||||
level=logging.INFO,
|
||||
format='%(asctime)s - %(name)s - %(levelname)s - %(message)s'
|
||||
)
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# --- 辅助函数 ---
|
||||
def get_orchestrator_from_config(config: dict) -> APITestOrchestrator:
|
||||
"""根据配置字典实例化APITestOrchestrator"""
|
||||
return APITestOrchestrator(
|
||||
base_url=config.get('base_url', ''),
|
||||
custom_test_cases_dir=config.get('custom_test_cases_dir'),
|
||||
llm_api_key=config.get('llm_api_key'),
|
||||
llm_base_url=config.get('llm_base_url'),
|
||||
llm_model_name=config.get('llm_model_name'),
|
||||
use_llm_for_request_body=config.get('use_llm_for_request_body', False),
|
||||
use_llm_for_path_params=config.get('use_llm_for_path_params', False),
|
||||
use_llm_for_query_params=config.get('use_llm_for_query_params', False),
|
||||
use_llm_for_headers=config.get('use_llm_for_headers', False),
|
||||
output_dir=config.get('output_dir') # 虽然Orchestrator内部可能不直接用它保存,但可以传入
|
||||
)
|
||||
|
||||
# --- API 端点 ---
|
||||
@app.route('/')
|
||||
def serve_index():
|
||||
return send_from_directory(app.static_folder, 'index.html')
|
||||
|
||||
@app.route('/run-tests', methods=['POST'])
|
||||
def run_tests_endpoint():
|
||||
try:
|
||||
config_data = request.json
|
||||
if not config_data:
|
||||
return jsonify({"error": "Request body must be JSON"}), 400
|
||||
|
||||
logger.info(f"接收到测试运行请求: {config_data}")
|
||||
|
||||
# 校验必需参数
|
||||
if not config_data.get('base_url'):
|
||||
return jsonify({"error": "'base_url' is required"}), 400
|
||||
if not config_data.get('yapi_file_path') and not config_data.get('swagger_file_path'):
|
||||
return jsonify({"error": "Either 'yapi_file_path' or 'swagger_file_path' is required"}), 400
|
||||
|
||||
orchestrator = get_orchestrator_from_config(config_data)
|
||||
summary = TestSummary() # 为本次运行创建新的摘要
|
||||
|
||||
parsed_spec = None
|
||||
api_spec_type = ""
|
||||
|
||||
if config_data.get('yapi_file_path'):
|
||||
api_spec_type = "YAPI"
|
||||
yapi_path = config_data['yapi_file_path']
|
||||
if not os.path.isabs(yapi_path):
|
||||
yapi_path = os.path.join(os.getcwd(), yapi_path) # 假设相对路径相对于服务器工作目录
|
||||
if not os.path.exists(yapi_path):
|
||||
return jsonify({"error": f"YAPI file not found: {yapi_path}"}), 400
|
||||
logger.info(f"解析YAPI文件: {yapi_path}")
|
||||
parsed_spec = orchestrator.parser.parse_yapi_spec(yapi_path)
|
||||
if not parsed_spec:
|
||||
logger.error(f"解析YAPI文件失败: {yapi_path}")
|
||||
return jsonify({"error": f"Failed to parse YAPI file: {yapi_path}"}), 500
|
||||
|
||||
elif config_data.get('swagger_file_path'):
|
||||
api_spec_type = "Swagger/OpenAPI"
|
||||
swagger_path = config_data['swagger_file_path']
|
||||
if not os.path.isabs(swagger_path):
|
||||
swagger_path = os.path.join(os.getcwd(), swagger_path) # 假设相对路径
|
||||
if not os.path.exists(swagger_path):
|
||||
return jsonify({"error": f"Swagger file not found: {swagger_path}"}), 400
|
||||
logger.info(f"解析Swagger/OpenAPI文件: {swagger_path}")
|
||||
parsed_spec = orchestrator.parser.parse_swagger_spec(swagger_path)
|
||||
if not parsed_spec:
|
||||
logger.error(f"解析Swagger文件失败: {swagger_path}")
|
||||
return jsonify({"error": f"Failed to parse Swagger file: {swagger_path}"}), 500
|
||||
|
||||
# 执行测试用例
|
||||
logger.info(f"开始从已解析的 {api_spec_type} 规范执行测试用例...")
|
||||
summary = orchestrator._execute_tests_from_parsed_spec(
|
||||
parsed_spec=parsed_spec,
|
||||
summary=summary,
|
||||
categories=config_data.get('categories'), # 逗号分隔的字符串,需要转为列表
|
||||
tags=config_data.get('tags'), # 同上
|
||||
custom_test_cases_dir=config_data.get('custom_test_cases_dir')
|
||||
)
|
||||
logger.info("测试用例执行完成。")
|
||||
|
||||
# 执行场景测试 (如果指定了目录)
|
||||
scenarios_dir = config_data.get('scenarios_dir')
|
||||
if scenarios_dir and parsed_spec:
|
||||
if not os.path.isabs(scenarios_dir):
|
||||
scenarios_dir = os.path.join(os.getcwd(), scenarios_dir)
|
||||
logger.info(f"开始执行API场景测试,目录: {scenarios_dir}")
|
||||
orchestrator.run_scenarios_from_spec(
|
||||
scenarios_dir=scenarios_dir,
|
||||
parsed_spec=parsed_spec,
|
||||
summary=summary
|
||||
)
|
||||
logger.info("API场景测试执行完毕。")
|
||||
|
||||
summary.finalize_summary() # 最终确定摘要,计算总时长等
|
||||
# summary.print_summary_to_console() # 后端服务通常不直接打印到控制台
|
||||
|
||||
# 可以在这里决定如何保存报告,例如保存到 output_dir (如果提供)
|
||||
output_dir_path_str = config_data.get('output_dir')
|
||||
main_report_file_path_str = ""
|
||||
api_calls_output_path_str = ""
|
||||
api_calls_filename = "api_call_details.md"
|
||||
|
||||
if output_dir_path_str:
|
||||
output_path = Path(output_dir_path_str)
|
||||
output_path.mkdir(parents=True, exist_ok=True)
|
||||
main_report_file_path = output_path / f"summary_report.json" # 默认保存为json
|
||||
main_report_file_path_str = str(main_report_file_path)
|
||||
with open(main_report_file_path, 'w', encoding='utf-8') as f:
|
||||
f.write(summary.to_json(pretty=True))
|
||||
logger.info(f"主测试报告已保存到: {main_report_file_path}")
|
||||
|
||||
# 保存API调用详情
|
||||
api_calls_output_path_str = str(output_path)
|
||||
# (需要从 run_api_tests.py 移植 save_api_call_details_to_file 或类似功能)
|
||||
# 暂时跳过保存 api_call_details 文件,因为 orchestrator.get_api_call_details() 需要被调用
|
||||
# 并且保存逻辑也需要移植。
|
||||
# save_api_call_details_to_file(orchestrator.get_api_call_details(), api_calls_output_path_str, api_calls_filename)
|
||||
|
||||
return jsonify({
|
||||
"message": "测试执行完成。",
|
||||
"summary": summary.to_dict(),
|
||||
"report_file": main_report_file_path_str, # 报告文件路径(如果保存了)
|
||||
# "api_calls_file": api_calls_output_path_str + "/" + api_calls_filename # API调用详情文件路径
|
||||
}), 200
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"执行测试时发生错误: {e}\n{traceback.format_exc()}")
|
||||
return jsonify({"error": f"执行测试时发生内部错误: {str(e)}"}), 500
|
||||
|
||||
@app.route('/list-yapi-categories', methods=['POST'])
|
||||
def list_yapi_categories_endpoint():
|
||||
try:
|
||||
data = request.json
|
||||
yapi_file = data.get('yapi_file_path')
|
||||
if not yapi_file:
|
||||
return jsonify({"error": "'yapi_file_path' is required"}), 400
|
||||
|
||||
if not os.path.isabs(yapi_file):
|
||||
yapi_file = os.path.join(os.getcwd(), yapi_file)
|
||||
if not os.path.exists(yapi_file):
|
||||
return jsonify({"error": f"YAPI file not found: {yapi_file}"}), 400
|
||||
|
||||
parser = InputParser()
|
||||
parsed_yapi = parser.parse_yapi_spec(yapi_file)
|
||||
if not parsed_yapi or not parsed_yapi.categories:
|
||||
return jsonify({"error": "Failed to parse YAPI categories or no categories found"}), 500
|
||||
|
||||
categories_list = [
|
||||
{"name": cat.get('name', '未命名'), "description": cat.get('desc', '无描述')}
|
||||
for cat in parsed_yapi.categories
|
||||
]
|
||||
return jsonify(categories_list), 200
|
||||
except Exception as e:
|
||||
logger.error(f"列出YAPI分类时出错: {e}\n{traceback.format_exc()}")
|
||||
return jsonify({"error": f"处理YAPI分类列表时出错: {str(e)}"}), 500
|
||||
|
||||
@app.route('/list-swagger-tags', methods=['POST'])
|
||||
def list_swagger_tags_endpoint():
|
||||
try:
|
||||
data = request.json
|
||||
swagger_file = data.get('swagger_file_path')
|
||||
if not swagger_file:
|
||||
return jsonify({"error": "'swagger_file_path' is required"}), 400
|
||||
|
||||
if not os.path.isabs(swagger_file):
|
||||
swagger_file = os.path.join(os.getcwd(), swagger_file)
|
||||
if not os.path.exists(swagger_file):
|
||||
return jsonify({"error": f"Swagger file not found: {swagger_file}"}), 400
|
||||
|
||||
parser = InputParser()
|
||||
parsed_swagger = parser.parse_swagger_spec(swagger_file)
|
||||
if not parsed_swagger or not parsed_swagger.tags:
|
||||
return jsonify({"error": "Failed to parse Swagger tags or no tags found"}), 500
|
||||
|
||||
tags_list = [
|
||||
{"name": tag.get('name', '未命名'), "description": tag.get('description', '无描述')}
|
||||
for tag in parsed_swagger.tags
|
||||
]
|
||||
return jsonify(tags_list), 200
|
||||
except Exception as e:
|
||||
logger.error(f"列出Swagger标签时出错: {e}\n{traceback.format_exc()}")
|
||||
return jsonify({"error": f"处理Swagger标签列表时出错: {str(e)}"}), 500
|
||||
|
||||
if __name__ == '__main__':
|
||||
# 注意:在生产环境中,应使用Gunicorn或uWSGI等WSGI服务器运行Flask应用
|
||||
app.run(debug=True, host='0.0.0.0', port=5050)
|
||||
2660
log_stage.txt
Normal file
2660
log_stage.txt
Normal file
File diff suppressed because it is too large
Load Diff
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@ -18,6 +18,7 @@ from typing import List, Optional
|
||||
|
||||
from ddms_compliance_suite.api_caller.caller import APICallDetail
|
||||
from ddms_compliance_suite.test_orchestrator import APITestOrchestrator, TestSummary
|
||||
from ddms_compliance_suite.input_parser.parser import ParsedAPISpec
|
||||
|
||||
# 配置日志
|
||||
logging.basicConfig(
|
||||
@ -84,6 +85,12 @@ def parse_args():
|
||||
default=False,
|
||||
help='是否启用LLM为API请求生成头部参数。')
|
||||
|
||||
# 新增:场景测试参数组
|
||||
scenario_group = parser.add_argument_group('API测试阶段 (Stage) 选项 (可选)')
|
||||
scenario_group.add_argument('--stages-dir',
|
||||
default=None,
|
||||
help='存放自定义APIStage Python文件的目录路径。如果未提供,则不执行测试阶段。')
|
||||
|
||||
return parser.parse_args()
|
||||
|
||||
def list_yapi_categories(yapi_file: str):
|
||||
@ -402,31 +409,63 @@ def main():
|
||||
use_llm_for_path_params=args.use_llm_for_path_params,
|
||||
use_llm_for_query_params=args.use_llm_for_query_params,
|
||||
use_llm_for_headers=args.use_llm_for_headers,
|
||||
output_dir=str(output_directory)
|
||||
output_dir=str(output_directory),
|
||||
stages_dir=args.stages_dir # 将 stages_dir 传递给编排器
|
||||
)
|
||||
|
||||
test_summary: Optional[TestSummary] = None
|
||||
parsed_spec_for_scenarios: Optional[ParsedAPISpec] = None # 用于存储已解析的规范,供场景使用
|
||||
|
||||
try:
|
||||
if args.yapi:
|
||||
logger.info(f"从YAPI文件运行测试: {args.yapi}")
|
||||
test_summary = orchestrator.run_tests_from_yapi(
|
||||
yapi_file_path=args.yapi,
|
||||
# orchestrator.run_tests_from_yapi 现在返回一个元组
|
||||
test_summary, parsed_spec_for_scenarios = orchestrator.run_tests_from_yapi(
|
||||
yapi_file_path=args.yapi,
|
||||
categories=categories,
|
||||
custom_test_cases_dir=args.custom_test_cases_dir
|
||||
)
|
||||
if not parsed_spec_for_scenarios: # 检查解析是否成功
|
||||
# orchestrator 内部应该已经记录了具体的解析错误
|
||||
logger.error(f"YAPI文件 '{args.yapi}' 解析失败 (由编排器报告)。程序将退出。")
|
||||
sys.exit(1)
|
||||
|
||||
elif args.swagger:
|
||||
logger.info(f"从Swagger文件运行测试: {args.swagger}")
|
||||
test_summary = orchestrator.run_tests_from_swagger(
|
||||
swagger_file_path=args.swagger,
|
||||
# orchestrator.run_tests_from_swagger 现在返回一个元组
|
||||
test_summary, parsed_spec_for_scenarios = orchestrator.run_tests_from_swagger(
|
||||
swagger_file_path=args.swagger,
|
||||
tags=tags,
|
||||
custom_test_cases_dir=args.custom_test_cases_dir
|
||||
)
|
||||
if not parsed_spec_for_scenarios: # 检查解析是否成功
|
||||
logger.error(f"Swagger文件 '{args.swagger}' 解析失败 (由编排器报告)。程序将退出。")
|
||||
sys.exit(1)
|
||||
# Deliberately not having an else here, as the initial check for yapi/swagger presence handles it.
|
||||
# If test_summary remains None here, it implies neither --yapi nor --swagger was processed correctly
|
||||
# or an unexpected path was taken, which should be caught by later checks or an error.
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"执行测试时发生意外错误: {e}", exc_info=True)
|
||||
logger.error(f"执行测试用例时发生意外错误: {e}", exc_info=True)
|
||||
sys.exit(1)
|
||||
|
||||
if test_summary:
|
||||
# 在保存单个测试用例结果之后,运行API测试阶段 (如果指定了目录)
|
||||
if args.stages_dir and parsed_spec_for_scenarios:
|
||||
logger.info(f"开始执行API测试阶段 (Stages),目录: {args.stages_dir}")
|
||||
# 注意:这里假设 test_orchestrator.py 中已经有了 run_stages_from_spec 方法
|
||||
# 并且 APITestOrchestrator 的 __init__ 也接受 stages_dir
|
||||
orchestrator.run_stages_from_spec( # 调用 run_stages_from_spec
|
||||
# stages_dir is managed by orchestrator's __init__
|
||||
parsed_spec=parsed_spec_for_scenarios, # 使用之前解析的规范
|
||||
summary=test_summary # 将阶段结果添加到同一个摘要对象
|
||||
)
|
||||
logger.info("API测试阶段 (Stages) 执行完毕。")
|
||||
# 阶段执行后,摘要已更新,重新最终确定和打印摘要
|
||||
test_summary.finalize_summary() # 重新计算总时长等
|
||||
test_summary.print_summary_to_console() # 打印包含阶段结果的更新摘要
|
||||
|
||||
# 保存主测试摘要 (现在可能包含测试阶段结果)
|
||||
save_results(test_summary, str(main_report_file_path), args.format)
|
||||
|
||||
api_calls_output_path_str: Optional[str] = None
|
||||
@ -486,4 +525,7 @@ if __name__ == '__main__':
|
||||
|
||||
# python run_api_tests.py --base-url https://127.0.0.1:4523/m1/6389742-6086420-default --yapi assets/doc/井筒API示例_simple.json --custom-test-cases-dir ./custom_testcases \
|
||||
# --verbose \
|
||||
# --output test_report.json
|
||||
# --output test_report.json
|
||||
|
||||
# 示例:同时运行测试用例和场景
|
||||
# python run_api_tests.py --base-url http://127.0.0.1:8000 --swagger ./assets/doc/petstore_swagger.json --custom-test-cases-dir ./custom_testcases --stages-dir ./custom_stages -v -o reports/
|
||||
96
static/index.html
Normal file
96
static/index.html
Normal file
@ -0,0 +1,96 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>API 测试工具</title>
|
||||
<link rel="stylesheet" href="style.css">
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<h1>API 合规性测试</h1>
|
||||
|
||||
<div class="configuration-section">
|
||||
<h2>测试配置</h2>
|
||||
<form id="test-config-form">
|
||||
<div class="form-group">
|
||||
<label for="base_url">API 基础URL (必填):</label>
|
||||
<input type="text" id="base_url" name="base_url" required placeholder="例如:http://localhost:8080/api/v1">
|
||||
</div>
|
||||
|
||||
<fieldset>
|
||||
<legend>API 定义源 (选择一个)</legend>
|
||||
<div class="form-group">
|
||||
<label for="yapi_file_path">YAPI 文件路径:</label>
|
||||
<input type="text" id="yapi_file_path" name="yapi_file_path" placeholder="例如:./assets/doc/yapi_spec.json">
|
||||
<button type="button" class="action-button" onclick="fetchYapiCategories()">加载分类</button>
|
||||
<div id="yapi-categories-container" class="categories-tags-container"></div>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="swagger_file_path">Swagger/OpenAPI 文件路径:</label>
|
||||
<input type="text" id="swagger_file_path" name="swagger_file_path" placeholder="例如:./assets/doc/swagger_spec.json">
|
||||
<button type="button" class="action-button" onclick="fetchSwaggerTags()">加载标签</button>
|
||||
<div id="swagger-tags-container" class="categories-tags-container"></div>
|
||||
</div>
|
||||
</fieldset>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="custom_test_cases_dir">自定义测试用例目录:</label>
|
||||
<input type="text" id="custom_test_cases_dir" name="custom_test_cases_dir" placeholder="例如:./custom_testcases">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="scenarios_dir">自定义场景目录:</label>
|
||||
<input type="text" id="scenarios_dir" name="scenarios_dir" placeholder="例如:./custom_scenarios">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="output_dir">报告输出目录:</label>
|
||||
<input type="text" id="output_dir" name="output_dir" placeholder="例如:./test_reports">
|
||||
</div>
|
||||
|
||||
<fieldset>
|
||||
<legend>LLM 配置 (可选)</legend>
|
||||
<div class="form-group">
|
||||
<label for="llm_api_key">LLM API Key:</label>
|
||||
<input type="password" id="llm_api_key" name="llm_api_key" placeholder="留空则尝试读取环境变量">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="llm_base_url">LLM Base URL:</label>
|
||||
<input type="text" id="llm_base_url" name="llm_base_url" placeholder="例如:https://dashscope.aliyuncs.com/compatible-mode/v1">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="llm_model_name">LLM 模型名称:</label>
|
||||
<input type="text" id="llm_model_name" name="llm_model_name" placeholder="例如:qwen-plus">
|
||||
</div>
|
||||
<div class="form-group checkbox-group">
|
||||
<input type="checkbox" id="use_llm_for_request_body" name="use_llm_for_request_body">
|
||||
<label for="use_llm_for_request_body">使用LLM生成请求体</label>
|
||||
</div>
|
||||
<div class="form-group checkbox-group">
|
||||
<input type="checkbox" id="use_llm_for_path_params" name="use_llm_for_path_params">
|
||||
<label for="use_llm_for_path_params">使用LLM生成路径参数</label>
|
||||
</div>
|
||||
<div class="form-group checkbox-group">
|
||||
<input type="checkbox" id="use_llm_for_query_params" name="use_llm_for_query_params">
|
||||
<label for="use_llm_for_query_params">使用LLM生成查询参数</label>
|
||||
</div>
|
||||
<div class="form-group checkbox-group">
|
||||
<input type="checkbox" id="use_llm_for_headers" name="use_llm_for_headers">
|
||||
<label for="use_llm_for_headers">使用LLM生成头部参数</label>
|
||||
</div>
|
||||
</fieldset>
|
||||
|
||||
<button type="submit" class="submit-button">运行测试</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<div class="results-section">
|
||||
<h2>测试状态与结果</h2>
|
||||
<div id="status-area">等待配置并运行测试...</div>
|
||||
<pre id="results-output"></pre>
|
||||
<div id="report-link-area"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script src="script.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
140
static/script.js
Normal file
140
static/script.js
Normal file
@ -0,0 +1,140 @@
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
const form = document.getElementById('test-config-form');
|
||||
const statusArea = document.getElementById('status-area');
|
||||
const resultsOutput = document.getElementById('results-output');
|
||||
const reportLinkArea = document.getElementById('report-link-area');
|
||||
|
||||
form.addEventListener('submit', async (event) => {
|
||||
event.preventDefault();
|
||||
statusArea.textContent = '正在运行测试,请稍候...';
|
||||
resultsOutput.textContent = '';
|
||||
reportLinkArea.innerHTML = '';
|
||||
|
||||
const formData = new FormData(form);
|
||||
const config = {};
|
||||
formData.forEach((value, key) => {
|
||||
// 处理复选框
|
||||
if (key.startsWith('use_llm_for_')) {
|
||||
config[key] = form.elements[key].checked;
|
||||
} else if (value.trim() !== '') { // 只添加非空值
|
||||
config[key] = value.trim();
|
||||
}
|
||||
});
|
||||
|
||||
// 如果复选框未被选中,FormData 不会包含它们,所以要确保它们是 false
|
||||
['use_llm_for_request_body', 'use_llm_for_path_params', 'use_llm_for_query_params', 'use_llm_for_headers'].forEach(key => {
|
||||
if (!(key in config)) {
|
||||
config[key] = false;
|
||||
}
|
||||
});
|
||||
|
||||
// 从 YAPI 分类和 Swagger 标签中获取选中的项
|
||||
const selectedYapiCategories = Array.from(document.querySelectorAll('#yapi-categories-container input[type="checkbox"]:checked'))
|
||||
.map(cb => cb.value);
|
||||
if (selectedYapiCategories.length > 0) {
|
||||
config['categories'] = selectedYapiCategories.join(',');
|
||||
}
|
||||
|
||||
const selectedSwaggerTags = Array.from(document.querySelectorAll('#swagger-tags-container input[type="checkbox"]:checked'))
|
||||
.map(cb => cb.value);
|
||||
if (selectedSwaggerTags.length > 0) {
|
||||
config['tags'] = selectedSwaggerTags.join(',');
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch('/run-tests', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify(config)
|
||||
});
|
||||
|
||||
const result = await response.json();
|
||||
|
||||
if (response.ok) {
|
||||
statusArea.textContent = `测试完成: ${result.message || '成功'}`;
|
||||
resultsOutput.textContent = JSON.stringify(result.summary, null, 2);
|
||||
if (result.report_file) {
|
||||
reportLinkArea.innerHTML = `<p>测试报告已保存到: <strong>${result.report_file}</strong></p>`;
|
||||
}
|
||||
} else {
|
||||
statusArea.textContent = `测试失败: ${result.error || '未知错误'}`;
|
||||
resultsOutput.textContent = JSON.stringify(result, null, 2);
|
||||
}
|
||||
} catch (error) {
|
||||
statusArea.textContent = '运行测试时发生网络错误或服务器内部错误。';
|
||||
resultsOutput.textContent = error.toString();
|
||||
console.error('运行测试出错:', error);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
async function fetchYapiCategories() {
|
||||
const yapiFilePath = document.getElementById('yapi_file_path').value;
|
||||
const container = document.getElementById('yapi-categories-container');
|
||||
container.innerHTML = '正在加载分类...';
|
||||
|
||||
if (!yapiFilePath) {
|
||||
container.innerHTML = '<p style="color: red;">请输入YAPI文件路径。</p>';
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch('/list-yapi-categories', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ yapi_file_path: yapiFilePath })
|
||||
});
|
||||
const categories = await response.json();
|
||||
if (response.ok) {
|
||||
renderCheckboxes(container, categories, 'yapi_category');
|
||||
} else {
|
||||
container.innerHTML = `<p style="color: red;">加载YAPI分类失败: ${categories.error || '未知错误'}</p>`;
|
||||
}
|
||||
} catch (error) {
|
||||
container.innerHTML = `<p style="color: red;">请求YAPI分类时出错: ${error}</p>`;
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchSwaggerTags() {
|
||||
const swaggerFilePath = document.getElementById('swagger_file_path').value;
|
||||
const container = document.getElementById('swagger-tags-container');
|
||||
container.innerHTML = '正在加载标签...';
|
||||
|
||||
if (!swaggerFilePath) {
|
||||
container.innerHTML = '<p style="color: red;">请输入Swagger文件路径。</p>';
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch('/list-swagger-tags', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ swagger_file_path: swaggerFilePath })
|
||||
});
|
||||
const tags = await response.json();
|
||||
if (response.ok) {
|
||||
renderCheckboxes(container, tags, 'swagger_tag');
|
||||
} else {
|
||||
container.innerHTML = `<p style="color: red;">加载Swagger标签失败: ${tags.error || '未知错误'}</p>`;
|
||||
}
|
||||
} catch (error) {
|
||||
container.innerHTML = `<p style="color: red;">请求Swagger标签时出错: ${error}</p>`;
|
||||
}
|
||||
}
|
||||
|
||||
function renderCheckboxes(container, items, groupName) {
|
||||
if (!items || items.length === 0) {
|
||||
container.innerHTML = '<p>未找到任何项。</p>';
|
||||
return;
|
||||
}
|
||||
let html = items.map((item, index) => {
|
||||
const id = `${groupName}_${index}`;
|
||||
return `<div>
|
||||
<input type="checkbox" id="${id}" name="${groupName}[]" value="${item.name}">
|
||||
<label for="${id}">${item.name} ${item.description ? '(' + item.description + ')' : ''}</label>
|
||||
</div>`;
|
||||
}).join('');
|
||||
container.innerHTML = html;
|
||||
}
|
||||
161
static/style.css
Normal file
161
static/style.css
Normal file
@ -0,0 +1,161 @@
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Oxygen, Ubuntu, Cantarell, "Open Sans", "Helvetica Neue", sans-serif;
|
||||
line-height: 1.6;
|
||||
margin: 0;
|
||||
padding: 20px;
|
||||
background-color: #f4f7f6;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.container {
|
||||
max-width: 900px;
|
||||
margin: 0 auto;
|
||||
background-color: #fff;
|
||||
padding: 25px;
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 2px 10px rgba(0,0,0,0.1);
|
||||
}
|
||||
|
||||
h1 {
|
||||
color: #2c3e50;
|
||||
text-align: center;
|
||||
margin-bottom: 25px;
|
||||
}
|
||||
|
||||
h2 {
|
||||
color: #34495e;
|
||||
border-bottom: 2px solid #ecf0f1;
|
||||
padding-bottom: 10px;
|
||||
margin-top: 30px;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.form-group {
|
||||
margin-bottom: 18px;
|
||||
}
|
||||
|
||||
.form-group label {
|
||||
display: block;
|
||||
margin-bottom: 6px;
|
||||
font-weight: bold;
|
||||
color: #555;
|
||||
}
|
||||
|
||||
.form-group input[type="text"],
|
||||
.form-group input[type="password"],
|
||||
.form-group input[type="url"] {
|
||||
width: calc(100% - 22px);
|
||||
padding: 10px;
|
||||
border: 1px solid #ccc;
|
||||
border-radius: 4px;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.form-group input[type="text"]:focus,
|
||||
.form-group input[type="password"]:focus,
|
||||
.form-group input[type="url"]:focus {
|
||||
border-color: #3498db;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.checkbox-group label {
|
||||
font-weight: normal;
|
||||
display: inline-block;
|
||||
margin-left: 5px;
|
||||
}
|
||||
.checkbox-group input[type="checkbox"] {
|
||||
margin-right: 5px;
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
fieldset {
|
||||
border: 1px solid #ddd;
|
||||
padding: 15px;
|
||||
border-radius: 4px;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
legend {
|
||||
padding: 0 10px;
|
||||
font-weight: bold;
|
||||
color: #3498db;
|
||||
}
|
||||
|
||||
.action-button {
|
||||
background-color: #3498db;
|
||||
color: white;
|
||||
padding: 8px 12px;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
font-size: 0.9em;
|
||||
margin-left: 10px;
|
||||
}
|
||||
|
||||
.action-button:hover {
|
||||
background-color: #2980b9;
|
||||
}
|
||||
|
||||
.submit-button {
|
||||
background-color: #2ecc71;
|
||||
color: white;
|
||||
padding: 12px 20px;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
font-size: 1.1em;
|
||||
display: block;
|
||||
width: 100%;
|
||||
margin-top: 20px;
|
||||
}
|
||||
|
||||
.submit-button:hover {
|
||||
background-color: #27ae60;
|
||||
}
|
||||
|
||||
.results-section {
|
||||
margin-top: 30px;
|
||||
}
|
||||
|
||||
#status-area {
|
||||
font-weight: bold;
|
||||
margin-bottom: 15px;
|
||||
padding: 10px;
|
||||
border-radius: 4px;
|
||||
background-color: #ecf0f1;
|
||||
border: 1px solid #bdc3c7;
|
||||
}
|
||||
|
||||
#results-output {
|
||||
background-color: #2c3e50;
|
||||
color: #ecf0f1;
|
||||
padding: 15px;
|
||||
border-radius: 4px;
|
||||
white-space: pre-wrap; /* Allows wrapping and preserves whitespace */
|
||||
word-wrap: break-word; /* Breaks long words to prevent overflow */
|
||||
max-height: 500px;
|
||||
overflow-y: auto;
|
||||
font-family: "Courier New", Courier, monospace;
|
||||
}
|
||||
|
||||
#report-link-area p {
|
||||
margin-top: 10px;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.categories-tags-container {
|
||||
margin-top: 10px;
|
||||
padding: 10px;
|
||||
background-color: #f9f9f9;
|
||||
border: 1px solid #eee;
|
||||
border-radius: 4px;
|
||||
max-height: 150px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.categories-tags-container div {
|
||||
margin-bottom: 5px;
|
||||
}
|
||||
.categories-tags-container label {
|
||||
font-weight: normal;
|
||||
}
|
||||
349
test_report.json
349
test_report.json
@ -1,8 +1,8 @@
|
||||
{
|
||||
"summary_metadata": {
|
||||
"start_time": "2025-05-29T16:45:25.849307",
|
||||
"end_time": "2025-05-29T16:45:26.621679",
|
||||
"duration_seconds": "0.77"
|
||||
"start_time": "2025-06-05T13:18:23.490837",
|
||||
"end_time": null,
|
||||
"duration_seconds": "0.00"
|
||||
},
|
||||
"endpoint_stats": {
|
||||
"total_defined": 6,
|
||||
@ -28,9 +28,9 @@
|
||||
"endpoint_id": "POST /api/dms/{dms_instance_code}/v1/message/push/{schema}/{version}",
|
||||
"endpoint_name": "数据推送接口",
|
||||
"overall_status": "失败",
|
||||
"duration_seconds": 0.195803,
|
||||
"start_time": "2025-05-29T16:45:25.849631",
|
||||
"end_time": "2025-05-29T16:45:26.045434",
|
||||
"duration_seconds": 0.465053,
|
||||
"start_time": "2025-06-05T13:18:23.491218",
|
||||
"end_time": "2025-06-05T13:18:23.956271",
|
||||
"executed_test_cases": [
|
||||
{
|
||||
"test_case_id": "TC-STATUS-001",
|
||||
@ -38,8 +38,8 @@
|
||||
"test_case_severity": "严重",
|
||||
"status": "通过",
|
||||
"message": "响应状态码为 200,符合预期 200。",
|
||||
"duration_seconds": 0.06710091698914766,
|
||||
"timestamp": "2025-05-29T16:45:25.916804",
|
||||
"duration_seconds": 0.27044441597536206,
|
||||
"timestamp": "2025-06-05T13:18:23.761746",
|
||||
"validation_points": [
|
||||
{
|
||||
"passed": true,
|
||||
@ -53,8 +53,8 @@
|
||||
"test_case_severity": "严重",
|
||||
"status": "通过",
|
||||
"message": "Schema验证步骤完成(未发现问题,或schema不适用/未为此响应定义)。",
|
||||
"duration_seconds": 0.024326875107362866,
|
||||
"timestamp": "2025-05-29T16:45:25.941204",
|
||||
"duration_seconds": 0.0601590839214623,
|
||||
"timestamp": "2025-06-05T13:18:23.821970",
|
||||
"validation_points": [
|
||||
{
|
||||
"passed": true,
|
||||
@ -68,8 +68,8 @@
|
||||
"test_case_severity": "严重",
|
||||
"status": "失败",
|
||||
"message": "API通过HTTP (http://127.0.0.1:4523/m1/6389742-6086420-default/api/dms/example_dms_instance_code/v1/message/push/example_schema/example_version) 响应了成功的状态码 200,这违反了HTTPS强制策略。",
|
||||
"duration_seconds": 0.019037832971662283,
|
||||
"timestamp": "2025-05-29T16:45:25.960309",
|
||||
"duration_seconds": 0.025706333806738257,
|
||||
"timestamp": "2025-06-05T13:18:23.847743",
|
||||
"validation_points": [
|
||||
{
|
||||
"status_code": 200
|
||||
@ -82,8 +82,8 @@
|
||||
"test_case_severity": "中",
|
||||
"status": "通过",
|
||||
"message": "跳过测试:在查询参数中未找到合适的字段来测试类型不匹配。",
|
||||
"duration_seconds": 0.0200852919369936,
|
||||
"timestamp": "2025-05-29T16:45:25.980454",
|
||||
"duration_seconds": 0.03199487505480647,
|
||||
"timestamp": "2025-06-05T13:18:23.879789",
|
||||
"validation_points": [
|
||||
{
|
||||
"passed": true,
|
||||
@ -96,35 +96,28 @@
|
||||
"test_case_name": "Error Code 4001 - Request Body Type Mismatch Validation",
|
||||
"test_case_severity": "中",
|
||||
"status": "失败",
|
||||
"message": "当请求体字段 'isSearchCount' 类型不匹配时,期望API返回状态码在 [400, 422] 中,或返回业务码为 '4001'. 实际收到状态码 200. 响应体中的业务码 ('code') 为 '44'.",
|
||||
"duration_seconds": 0.02025704109109938,
|
||||
"timestamp": "2025-05-29T16:45:26.000770",
|
||||
"message": "当请求体字段 'isSearchCount' 类型不匹配时,期望API返回状态码在 [400, 422] 中,或返回4xx客户端错误且业务码为 '4001'. 实际收到状态码 200. 响应体中的业务码 ('code') 为 '94'.",
|
||||
"duration_seconds": 0.039490207796916366,
|
||||
"timestamp": "2025-06-05T13:18:23.919334",
|
||||
"validation_points": [
|
||||
{
|
||||
"status_code": 200,
|
||||
"response_body": {
|
||||
"code": 44,
|
||||
"message": "cupidatat ipsum exercitation",
|
||||
"code": 94,
|
||||
"message": "irure nulla fugiat sit",
|
||||
"data": {
|
||||
"total": 84,
|
||||
"total": 86,
|
||||
"list": [
|
||||
{
|
||||
"dsid": "56",
|
||||
"dataRegion": "culpa et nisi",
|
||||
"dsid": "39",
|
||||
"dataRegion": "qui sed in",
|
||||
"gasReleaseMon": null,
|
||||
"gasReleaseYear": null,
|
||||
"releaseGasCum": null
|
||||
},
|
||||
{
|
||||
"dsid": "22",
|
||||
"dataRegion": "ea nulla in tempor proident",
|
||||
"gasReleaseMon": null,
|
||||
"gasReleaseYear": null,
|
||||
"releaseGasCum": null
|
||||
},
|
||||
{
|
||||
"dsid": "69",
|
||||
"dataRegion": "adipisicing dolore velit Excepteur",
|
||||
"dsid": "87",
|
||||
"dataRegion": "deserunt aute irure",
|
||||
"gasReleaseMon": null,
|
||||
"gasReleaseYear": null,
|
||||
"releaseGasCum": null
|
||||
@ -147,8 +140,8 @@
|
||||
"test_case_severity": "高",
|
||||
"status": "通过",
|
||||
"message": "跳过测试:在API规范中未找到合适的必填请求体字段用于移除测试。",
|
||||
"duration_seconds": 0.02807041583582759,
|
||||
"timestamp": "2025-05-29T16:45:26.028894",
|
||||
"duration_seconds": 0.017872040858492255,
|
||||
"timestamp": "2025-06-05T13:18:23.937261",
|
||||
"validation_points": [
|
||||
{
|
||||
"passed": true,
|
||||
@ -162,8 +155,8 @@
|
||||
"test_case_severity": "高",
|
||||
"status": "通过",
|
||||
"message": "跳过测试:在API规范中未找到合适的必填查询参数用于移除测试。",
|
||||
"duration_seconds": 0.016444000182673335,
|
||||
"timestamp": "2025-05-29T16:45:26.045394",
|
||||
"duration_seconds": 0.01888895803131163,
|
||||
"timestamp": "2025-06-05T13:18:23.956219",
|
||||
"validation_points": [
|
||||
{
|
||||
"passed": true,
|
||||
@ -177,9 +170,9 @@
|
||||
"endpoint_id": "POST /api/dms/{dms_instance_code}/v1/cd_geo_unit/{version}",
|
||||
"endpoint_name": "地质单元列表查询",
|
||||
"overall_status": "失败",
|
||||
"duration_seconds": 0.121974,
|
||||
"start_time": "2025-05-29T16:45:26.045467",
|
||||
"end_time": "2025-05-29T16:45:26.167441",
|
||||
"duration_seconds": 0.138585,
|
||||
"start_time": "2025-06-05T13:18:23.956310",
|
||||
"end_time": "2025-06-05T13:18:24.094895",
|
||||
"executed_test_cases": [
|
||||
{
|
||||
"test_case_id": "TC-STATUS-001",
|
||||
@ -187,8 +180,8 @@
|
||||
"test_case_severity": "严重",
|
||||
"status": "通过",
|
||||
"message": "响应状态码为 200,符合预期 200。",
|
||||
"duration_seconds": 0.0186984168831259,
|
||||
"timestamp": "2025-05-29T16:45:26.064250",
|
||||
"duration_seconds": 0.022716667037457228,
|
||||
"timestamp": "2025-06-05T13:18:23.979121",
|
||||
"validation_points": [
|
||||
{
|
||||
"passed": true,
|
||||
@ -202,8 +195,8 @@
|
||||
"test_case_severity": "严重",
|
||||
"status": "通过",
|
||||
"message": "Schema验证步骤完成(未发现问题,或schema不适用/未为此响应定义)。",
|
||||
"duration_seconds": 0.021727959159761667,
|
||||
"timestamp": "2025-05-29T16:45:26.086038",
|
||||
"duration_seconds": 0.017374124843627214,
|
||||
"timestamp": "2025-06-05T13:18:23.996552",
|
||||
"validation_points": [
|
||||
{
|
||||
"passed": true,
|
||||
@ -217,8 +210,8 @@
|
||||
"test_case_severity": "严重",
|
||||
"status": "失败",
|
||||
"message": "API通过HTTP (http://127.0.0.1:4523/m1/6389742-6086420-default/api/dms/example_dms_instance_code/v1/cd_geo_unit/1.0.0) 响应了成功的状态码 200,这违反了HTTPS强制策略。",
|
||||
"duration_seconds": 0.017182791838422418,
|
||||
"timestamp": "2025-05-29T16:45:26.103281",
|
||||
"duration_seconds": 0.019951834110543132,
|
||||
"timestamp": "2025-06-05T13:18:24.016559",
|
||||
"validation_points": [
|
||||
{
|
||||
"status_code": 200
|
||||
@ -230,21 +223,28 @@
|
||||
"test_case_name": "Error Code 4001 - Query Parameter Type Mismatch Validation",
|
||||
"test_case_severity": "中",
|
||||
"status": "失败",
|
||||
"message": "当查询参数 'pageNo' (路径: 'pageNo') 类型不匹配时,期望API返回状态码在 [400, 422] 中,或返回4xx客户端错误且业务码为 '4001'. 实际收到状态码 200. 响应体中的业务码 ('code') 为 '30'.",
|
||||
"duration_seconds": 0.016620707930997014,
|
||||
"timestamp": "2025-05-29T16:45:26.119956",
|
||||
"message": "当查询参数 'pageNo' (路径: 'pageNo') 类型不匹配时,期望API返回状态码在 [400, 422] 中,或返回4xx客户端错误且业务码为 '4001'. 实际收到状态码 200. 响应体中的业务码 ('code') 为 '53'.",
|
||||
"duration_seconds": 0.018517875112593174,
|
||||
"timestamp": "2025-06-05T13:18:24.035132",
|
||||
"validation_points": [
|
||||
{
|
||||
"status_code": 200,
|
||||
"response_body": {
|
||||
"code": 30,
|
||||
"message": "ut incididunt aliquip laborum",
|
||||
"code": 53,
|
||||
"message": "velit sed",
|
||||
"data": {
|
||||
"total": 45,
|
||||
"total": 28,
|
||||
"list": [
|
||||
{
|
||||
"dsid": "86",
|
||||
"dataRegion": "aliqua ipsum labore ea",
|
||||
"dsid": "77",
|
||||
"dataRegion": "deserunt",
|
||||
"gasReleaseMon": null,
|
||||
"gasReleaseYear": null,
|
||||
"releaseGasCum": null
|
||||
},
|
||||
{
|
||||
"dsid": "43",
|
||||
"dataRegion": "quis anim ea pariatur in",
|
||||
"gasReleaseMon": null,
|
||||
"gasReleaseYear": null,
|
||||
"releaseGasCum": null
|
||||
@ -266,28 +266,28 @@
|
||||
"test_case_name": "Error Code 4001 - Request Body Type Mismatch Validation",
|
||||
"test_case_severity": "中",
|
||||
"status": "失败",
|
||||
"message": "当请求体字段 'isSearchCount' 类型不匹配时,期望API返回状态码在 [400, 422] 中,或返回4xx客户端错误且业务码为 '4001'. 实际收到状态码 200. 响应体中的业务码 ('code') 为 '93'.",
|
||||
"duration_seconds": 0.01504912506788969,
|
||||
"timestamp": "2025-05-29T16:45:26.135053",
|
||||
"message": "当请求体字段 'isSearchCount' 类型不匹配时,期望API返回状态码在 [400, 422] 中,或返回4xx客户端错误且业务码为 '4001'. 实际收到状态码 200. 响应体中的业务码 ('code') 为 '36'.",
|
||||
"duration_seconds": 0.02283320901915431,
|
||||
"timestamp": "2025-06-05T13:18:24.058027",
|
||||
"validation_points": [
|
||||
{
|
||||
"status_code": 200,
|
||||
"response_body": {
|
||||
"code": 93,
|
||||
"message": "irure in aute aliquip dolor",
|
||||
"code": 36,
|
||||
"message": "in voluptate commodo",
|
||||
"data": {
|
||||
"total": 14,
|
||||
"total": 37,
|
||||
"list": [
|
||||
{
|
||||
"dsid": "49",
|
||||
"dataRegion": "pariatur nulla",
|
||||
"dsid": "59",
|
||||
"dataRegion": "amet occaecat deserunt ex pariatur",
|
||||
"gasReleaseMon": null,
|
||||
"gasReleaseYear": null,
|
||||
"releaseGasCum": null
|
||||
},
|
||||
{
|
||||
"dsid": "47",
|
||||
"dataRegion": "ullamco anim incididunt culpa",
|
||||
"dsid": "20",
|
||||
"dataRegion": "aute officia deserunt in",
|
||||
"gasReleaseMon": null,
|
||||
"gasReleaseYear": null,
|
||||
"releaseGasCum": null
|
||||
@ -310,8 +310,8 @@
|
||||
"test_case_severity": "高",
|
||||
"status": "通过",
|
||||
"message": "跳过测试:在API规范中未找到合适的必填请求体字段用于移除测试。",
|
||||
"duration_seconds": 0.01566412509419024,
|
||||
"timestamp": "2025-05-29T16:45:26.150763",
|
||||
"duration_seconds": 0.016807042062282562,
|
||||
"timestamp": "2025-06-05T13:18:24.075022",
|
||||
"validation_points": [
|
||||
{
|
||||
"passed": true,
|
||||
@ -325,8 +325,8 @@
|
||||
"test_case_severity": "高",
|
||||
"status": "通过",
|
||||
"message": "跳过测试:在API规范中未找到合适的必填查询参数用于移除测试。",
|
||||
"duration_seconds": 0.016583458986133337,
|
||||
"timestamp": "2025-05-29T16:45:26.167396",
|
||||
"duration_seconds": 0.01974637503735721,
|
||||
"timestamp": "2025-06-05T13:18:24.094848",
|
||||
"validation_points": [
|
||||
{
|
||||
"passed": true,
|
||||
@ -340,9 +340,9 @@
|
||||
"endpoint_id": "PUT /api/dms/{dms_instance_code}/v1/cd_geo_unit",
|
||||
"endpoint_name": "地质单元数据修改",
|
||||
"overall_status": "失败",
|
||||
"duration_seconds": 0.110383,
|
||||
"start_time": "2025-05-29T16:45:26.167471",
|
||||
"end_time": "2025-05-29T16:45:26.277854",
|
||||
"duration_seconds": 0.144021,
|
||||
"start_time": "2025-06-05T13:18:24.094928",
|
||||
"end_time": "2025-06-05T13:18:24.238949",
|
||||
"executed_test_cases": [
|
||||
{
|
||||
"test_case_id": "TC-STATUS-001",
|
||||
@ -350,8 +350,8 @@
|
||||
"test_case_severity": "严重",
|
||||
"status": "通过",
|
||||
"message": "响应状态码为 200,符合预期 200。",
|
||||
"duration_seconds": 0.01697887503542006,
|
||||
"timestamp": "2025-05-29T16:45:26.184543",
|
||||
"duration_seconds": 0.026633291970938444,
|
||||
"timestamp": "2025-06-05T13:18:24.121653",
|
||||
"validation_points": [
|
||||
{
|
||||
"passed": true,
|
||||
@ -365,8 +365,8 @@
|
||||
"test_case_severity": "严重",
|
||||
"status": "通过",
|
||||
"message": "针对 PUT http://127.0.0.1:4523/m1/6389742-6086420-default/api/dms/example_dms_instance_code/v1/cd_geo_unit (状态码 200) 的响应体 conforms to the JSON schema.",
|
||||
"duration_seconds": 0.01832004194147885,
|
||||
"timestamp": "2025-05-29T16:45:26.202912",
|
||||
"duration_seconds": 0.024359666975215077,
|
||||
"timestamp": "2025-06-05T13:18:24.146076",
|
||||
"validation_points": [
|
||||
{
|
||||
"passed": true,
|
||||
@ -380,8 +380,8 @@
|
||||
"test_case_severity": "严重",
|
||||
"status": "失败",
|
||||
"message": "API通过HTTP (http://127.0.0.1:4523/m1/6389742-6086420-default/api/dms/example_dms_instance_code/v1/cd_geo_unit) 响应了成功的状态码 200,这违反了HTTPS强制策略。",
|
||||
"duration_seconds": 0.016209041001275182,
|
||||
"timestamp": "2025-05-29T16:45:26.219217",
|
||||
"duration_seconds": 0.017260834109038115,
|
||||
"timestamp": "2025-06-05T13:18:24.163411",
|
||||
"validation_points": [
|
||||
{
|
||||
"status_code": 200
|
||||
@ -393,15 +393,15 @@
|
||||
"test_case_name": "Error Code 4001 - Query Parameter Type Mismatch Validation",
|
||||
"test_case_severity": "中",
|
||||
"status": "失败",
|
||||
"message": "当查询参数 'id' (路径: 'id') 类型不匹配时,期望API返回状态码在 [400, 422] 中,或返回4xx客户端错误且业务码为 '4001'. 实际收到状态码 200. 响应体中的业务码 ('code') 为 '55'.",
|
||||
"duration_seconds": 0.014930624980479479,
|
||||
"timestamp": "2025-05-29T16:45:26.234199",
|
||||
"message": "当查询参数 'id' (路径: 'id') 类型不匹配时,期望API返回状态码在 [400, 422] 中,或返回4xx客户端错误且业务码为 '4001'. 实际收到状态码 200. 响应体中的业务码 ('code') 为 '75'.",
|
||||
"duration_seconds": 0.019769666949287057,
|
||||
"timestamp": "2025-06-05T13:18:24.183238",
|
||||
"validation_points": [
|
||||
{
|
||||
"status_code": 200,
|
||||
"response_body": {
|
||||
"code": 55,
|
||||
"message": "tempor elit",
|
||||
"code": 75,
|
||||
"message": "dolore sit minim sint",
|
||||
"data": true
|
||||
},
|
||||
"expected_http_status_codes": [
|
||||
@ -418,16 +418,16 @@
|
||||
"test_case_name": "Error Code 4001 - Request Body Type Mismatch Validation",
|
||||
"test_case_severity": "中",
|
||||
"status": "失败",
|
||||
"message": "当请求体字段 'id' 类型不匹配时,期望API返回状态码在 [400, 422] 中,或返回4xx客户端错误且业务码为 '4001'. 实际收到状态码 200. 响应体中的业务码 ('code') 为 '54'.",
|
||||
"duration_seconds": 0.014813833869993687,
|
||||
"timestamp": "2025-05-29T16:45:26.249066",
|
||||
"message": "当请求体字段 'id' 类型不匹配时,期望API返回状态码在 [400, 422] 中,或返回4xx客户端错误且业务码为 '4001'. 实际收到状态码 200. 响应体中的业务码 ('code') 为 '33'.",
|
||||
"duration_seconds": 0.016951082972809672,
|
||||
"timestamp": "2025-06-05T13:18:24.200244",
|
||||
"validation_points": [
|
||||
{
|
||||
"status_code": 200,
|
||||
"response_body": {
|
||||
"code": 54,
|
||||
"message": "deserunt id incididunt",
|
||||
"data": false
|
||||
"code": 33,
|
||||
"message": "sit non esse ad enim",
|
||||
"data": true
|
||||
},
|
||||
"expected_http_status_codes": [
|
||||
400,
|
||||
@ -444,8 +444,8 @@
|
||||
"test_case_severity": "高",
|
||||
"status": "通过",
|
||||
"message": "当移除必填请求体字段 'id' 时,API响应了状态码 200 (非主要预期HTTP状态 [400, 422],但为4xx客户端错误), 且响应体中包含预期的业务错误码 '4003' (字段: 'code').",
|
||||
"duration_seconds": 0.013806292088702321,
|
||||
"timestamp": "2025-05-29T16:45:26.262916",
|
||||
"duration_seconds": 0.01893512485548854,
|
||||
"timestamp": "2025-06-05T13:18:24.219234",
|
||||
"validation_points": [
|
||||
{
|
||||
"passed": true,
|
||||
@ -458,16 +458,16 @@
|
||||
"test_case_name": "Error Code 4003 - Missing Required Query Parameter Validation",
|
||||
"test_case_severity": "高",
|
||||
"status": "失败",
|
||||
"message": "当移除必填查询参数 'id' 时,期望API返回状态码在 [400, 422] 中,或返回4xx客户端错误且业务码为 '4003'. 实际收到状态码 200. 响应体中的业务码 ('code') 为 '25'.",
|
||||
"duration_seconds": 0.014846875099465251,
|
||||
"timestamp": "2025-05-29T16:45:26.277818",
|
||||
"message": "当移除必填查询参数 'id' 时,期望API返回状态码在 [400, 422] 中,或返回4xx客户端错误且业务码为 '4003'. 实际收到状态码 200. 响应体中的业务码 ('code') 为 '69'.",
|
||||
"duration_seconds": 0.019623583182692528,
|
||||
"timestamp": "2025-06-05T13:18:24.238911",
|
||||
"validation_points": [
|
||||
{
|
||||
"status_code": 200,
|
||||
"response_body": {
|
||||
"code": 25,
|
||||
"message": "sint laboris ex ea",
|
||||
"data": true
|
||||
"code": 69,
|
||||
"message": "exercitation laborum cupidatat commodo",
|
||||
"data": false
|
||||
},
|
||||
"expected_http_status_codes": [
|
||||
400,
|
||||
@ -484,9 +484,9 @@
|
||||
"endpoint_id": "DELETE /api/dms/{dms_instance_code}/v1/cd_geo_unit",
|
||||
"endpoint_name": "地质单元数据删除",
|
||||
"overall_status": "失败",
|
||||
"duration_seconds": 0.111407,
|
||||
"start_time": "2025-05-29T16:45:26.277880",
|
||||
"end_time": "2025-05-29T16:45:26.389287",
|
||||
"duration_seconds": 0.108327,
|
||||
"start_time": "2025-06-05T13:18:24.238977",
|
||||
"end_time": "2025-06-05T13:18:24.347304",
|
||||
"executed_test_cases": [
|
||||
{
|
||||
"test_case_id": "TC-STATUS-001",
|
||||
@ -494,8 +494,8 @@
|
||||
"test_case_severity": "严重",
|
||||
"status": "通过",
|
||||
"message": "响应状态码为 200,符合预期 200。",
|
||||
"duration_seconds": 0.013204999966546893,
|
||||
"timestamp": "2025-05-29T16:45:26.291166",
|
||||
"duration_seconds": 0.013654708862304688,
|
||||
"timestamp": "2025-06-05T13:18:24.252718",
|
||||
"validation_points": [
|
||||
{
|
||||
"passed": true,
|
||||
@ -509,8 +509,8 @@
|
||||
"test_case_severity": "严重",
|
||||
"status": "通过",
|
||||
"message": "针对 DELETE http://127.0.0.1:4523/m1/6389742-6086420-default/api/dms/example_dms_instance_code/v1/cd_geo_unit (状态码 200) 的响应体 conforms to the JSON schema.",
|
||||
"duration_seconds": 0.01893095811828971,
|
||||
"timestamp": "2025-05-29T16:45:26.310139",
|
||||
"duration_seconds": 0.015607249923050404,
|
||||
"timestamp": "2025-06-05T13:18:24.268376",
|
||||
"validation_points": [
|
||||
{
|
||||
"passed": true,
|
||||
@ -524,8 +524,8 @@
|
||||
"test_case_severity": "严重",
|
||||
"status": "失败",
|
||||
"message": "API通过HTTP (http://127.0.0.1:4523/m1/6389742-6086420-default/api/dms/example_dms_instance_code/v1/cd_geo_unit) 响应了成功的状态码 200,这违反了HTTPS强制策略。",
|
||||
"duration_seconds": 0.013458125060424209,
|
||||
"timestamp": "2025-05-29T16:45:26.323654",
|
||||
"duration_seconds": 0.013847207883372903,
|
||||
"timestamp": "2025-06-05T13:18:24.282274",
|
||||
"validation_points": [
|
||||
{
|
||||
"status_code": 200
|
||||
@ -537,15 +537,15 @@
|
||||
"test_case_name": "Error Code 4001 - Query Parameter Type Mismatch Validation",
|
||||
"test_case_severity": "中",
|
||||
"status": "失败",
|
||||
"message": "当查询参数 'id' (路径: 'id') 类型不匹配时,期望API返回状态码在 [400, 422] 中,或返回4xx客户端错误且业务码为 '4001'. 实际收到状态码 200. 响应体中的业务码 ('code') 为 '36'.",
|
||||
"duration_seconds": 0.015051041962578893,
|
||||
"timestamp": "2025-05-29T16:45:26.338921",
|
||||
"message": "当查询参数 'id' (路径: 'id') 类型不匹配时,期望API返回状态码在 [400, 422] 中,或返回4xx客户端错误且业务码为 '4001'. 实际收到状态码 200. 响应体中的业务码 ('code') 为 '91'.",
|
||||
"duration_seconds": 0.014518292155116796,
|
||||
"timestamp": "2025-06-05T13:18:24.296839",
|
||||
"validation_points": [
|
||||
{
|
||||
"status_code": 200,
|
||||
"response_body": {
|
||||
"code": 36,
|
||||
"message": "nostrud aliqua consequat commodo",
|
||||
"code": 91,
|
||||
"message": "irure ex",
|
||||
"data": true
|
||||
},
|
||||
"expected_http_status_codes": [
|
||||
@ -562,15 +562,15 @@
|
||||
"test_case_name": "Error Code 4001 - Request Body Type Mismatch Validation",
|
||||
"test_case_severity": "中",
|
||||
"status": "失败",
|
||||
"message": "当请求体字段 'version' 类型不匹配时,期望API返回状态码在 [400, 422] 中,或返回4xx客户端错误且业务码为 '4001'. 实际收到状态码 200. 响应体中的业务码 ('code') 为 '91'.",
|
||||
"duration_seconds": 0.015764417126774788,
|
||||
"timestamp": "2025-05-29T16:45:26.354804",
|
||||
"message": "当请求体字段 'version' 类型不匹配时,期望API返回状态码在 [400, 422] 中,或返回4xx客户端错误且业务码为 '4001'. 实际收到状态码 200. 响应体中的业务码 ('code') 为 '2'.",
|
||||
"duration_seconds": 0.017365749925374985,
|
||||
"timestamp": "2025-06-05T13:18:24.314251",
|
||||
"validation_points": [
|
||||
{
|
||||
"status_code": 200,
|
||||
"response_body": {
|
||||
"code": 91,
|
||||
"message": "nisi eu deserunt laboris",
|
||||
"code": 2,
|
||||
"message": "aliqua pariatur",
|
||||
"data": false
|
||||
},
|
||||
"expected_http_status_codes": [
|
||||
@ -588,8 +588,8 @@
|
||||
"test_case_severity": "高",
|
||||
"status": "通过",
|
||||
"message": "跳过测试:在API规范中未找到合适的必填请求体字段用于移除测试。",
|
||||
"duration_seconds": 0.017602207837626338,
|
||||
"timestamp": "2025-05-29T16:45:26.372570",
|
||||
"duration_seconds": 0.018009459134191275,
|
||||
"timestamp": "2025-06-05T13:18:24.332313",
|
||||
"validation_points": [
|
||||
{
|
||||
"passed": true,
|
||||
@ -602,16 +602,16 @@
|
||||
"test_case_name": "Error Code 4003 - Missing Required Query Parameter Validation",
|
||||
"test_case_severity": "高",
|
||||
"status": "失败",
|
||||
"message": "当移除必填查询参数 'id' 时,期望API返回状态码在 [400, 422] 中,或返回4xx客户端错误且业务码为 '4003'. 实际收到状态码 200. 响应体中的业务码 ('code') 为 '68'.",
|
||||
"duration_seconds": 0.016222875099629164,
|
||||
"timestamp": "2025-05-29T16:45:26.389095",
|
||||
"message": "当移除必填查询参数 'id' 时,期望API返回状态码在 [400, 422] 中,或返回4xx客户端错误且业务码为 '4003'. 实际收到状态码 200. 响应体中的业务码 ('code') 为 '19'.",
|
||||
"duration_seconds": 0.014903625007718801,
|
||||
"timestamp": "2025-06-05T13:18:24.347267",
|
||||
"validation_points": [
|
||||
{
|
||||
"status_code": 200,
|
||||
"response_body": {
|
||||
"code": 68,
|
||||
"message": "esse ad consectetur",
|
||||
"data": true
|
||||
"code": 19,
|
||||
"message": "sed cillum sit nulla",
|
||||
"data": false
|
||||
},
|
||||
"expected_http_status_codes": [
|
||||
400,
|
||||
@ -628,9 +628,9 @@
|
||||
"endpoint_id": "POST /api/dms/{dms_instance_code}/v1/cd_geo_unit",
|
||||
"endpoint_name": "地质单元数据添加",
|
||||
"overall_status": "失败",
|
||||
"duration_seconds": 0.12343,
|
||||
"start_time": "2025-05-29T16:45:26.389467",
|
||||
"end_time": "2025-05-29T16:45:26.512897",
|
||||
"duration_seconds": 0.11047,
|
||||
"start_time": "2025-06-05T13:18:24.347330",
|
||||
"end_time": "2025-06-05T13:18:24.457800",
|
||||
"executed_test_cases": [
|
||||
{
|
||||
"test_case_id": "TC-STATUS-001",
|
||||
@ -638,8 +638,8 @@
|
||||
"test_case_severity": "严重",
|
||||
"status": "通过",
|
||||
"message": "响应状态码为 200,符合预期 200。",
|
||||
"duration_seconds": 0.017961708130314946,
|
||||
"timestamp": "2025-05-29T16:45:26.407964",
|
||||
"duration_seconds": 0.014181334059685469,
|
||||
"timestamp": "2025-06-05T13:18:24.361591",
|
||||
"validation_points": [
|
||||
{
|
||||
"passed": true,
|
||||
@ -653,8 +653,8 @@
|
||||
"test_case_severity": "严重",
|
||||
"status": "通过",
|
||||
"message": "Schema验证步骤完成(未发现问题,或schema不适用/未为此响应定义)。",
|
||||
"duration_seconds": 0.01862025004811585,
|
||||
"timestamp": "2025-05-29T16:45:26.427163",
|
||||
"duration_seconds": 0.01454712520353496,
|
||||
"timestamp": "2025-06-05T13:18:24.376190",
|
||||
"validation_points": [
|
||||
{
|
||||
"passed": true,
|
||||
@ -668,8 +668,8 @@
|
||||
"test_case_severity": "严重",
|
||||
"status": "失败",
|
||||
"message": "API通过HTTP (http://127.0.0.1:4523/m1/6389742-6086420-default/api/dms/example_dms_instance_code/v1/cd_geo_unit) 响应了成功的状态码 200,这违反了HTTPS强制策略。",
|
||||
"duration_seconds": 0.017223792150616646,
|
||||
"timestamp": "2025-05-29T16:45:26.444548",
|
||||
"duration_seconds": 0.013859666883945465,
|
||||
"timestamp": "2025-06-05T13:18:24.390099",
|
||||
"validation_points": [
|
||||
{
|
||||
"status_code": 200
|
||||
@ -682,8 +682,8 @@
|
||||
"test_case_severity": "中",
|
||||
"status": "通过",
|
||||
"message": "跳过测试:在查询参数中未找到合适的字段来测试类型不匹配。",
|
||||
"duration_seconds": 0.016695416998118162,
|
||||
"timestamp": "2025-05-29T16:45:26.461439",
|
||||
"duration_seconds": 0.015962708042934537,
|
||||
"timestamp": "2025-06-05T13:18:24.406113",
|
||||
"validation_points": [
|
||||
{
|
||||
"passed": true,
|
||||
@ -696,16 +696,16 @@
|
||||
"test_case_name": "Error Code 4001 - Request Body Type Mismatch Validation",
|
||||
"test_case_severity": "中",
|
||||
"status": "失败",
|
||||
"message": "当请求体字段 'version' 类型不匹配时,期望API返回状态码在 [400, 422] 中,或返回4xx客户端错误且业务码为 '4001'. 实际收到状态码 200. 响应体中的业务码 ('code') 为 '44'.",
|
||||
"duration_seconds": 0.01606720802374184,
|
||||
"timestamp": "2025-05-29T16:45:26.477669",
|
||||
"message": "当请求体字段 'version' 类型不匹配时,期望API返回状态码在 [400, 422] 中,或返回4xx客户端错误且业务码为 '4001'. 实际收到状态码 200. 响应体中的业务码 ('code') 为 '73'.",
|
||||
"duration_seconds": 0.015125166857615113,
|
||||
"timestamp": "2025-06-05T13:18:24.421291",
|
||||
"validation_points": [
|
||||
{
|
||||
"status_code": 200,
|
||||
"response_body": {
|
||||
"code": 44,
|
||||
"message": "enim cupidatat magna nulla",
|
||||
"data": true
|
||||
"code": 73,
|
||||
"message": "ullamco ex",
|
||||
"data": false
|
||||
},
|
||||
"expected_http_status_codes": [
|
||||
400,
|
||||
@ -721,16 +721,16 @@
|
||||
"test_case_name": "Error Code 4003 - Missing Required Request Body Field Validation",
|
||||
"test_case_severity": "高",
|
||||
"status": "失败",
|
||||
"message": "当移除必填请求体字段 'data.0.bsflag' 时,期望API返回状态码在 [400, 422] 中,或返回4xx客户端错误且业务码为 '4003'. 实际收到状态码 200. 响应体中的业务码 ('code') 为 '34'.",
|
||||
"duration_seconds": 0.018121875124052167,
|
||||
"timestamp": "2025-05-29T16:45:26.495933",
|
||||
"message": "当移除必填请求体字段 'data.0.bsflag' 时,期望API返回状态码在 [400, 422] 中,或返回4xx客户端错误且业务码为 '4003'. 实际收到状态码 200. 响应体中的业务码 ('code') 为 '72'.",
|
||||
"duration_seconds": 0.01929962495341897,
|
||||
"timestamp": "2025-06-05T13:18:24.440806",
|
||||
"validation_points": [
|
||||
{
|
||||
"status_code": 200,
|
||||
"response_body": {
|
||||
"code": 34,
|
||||
"message": "do consequat elit",
|
||||
"data": true
|
||||
"code": 72,
|
||||
"message": "dolore Excepteur",
|
||||
"data": false
|
||||
},
|
||||
"expected_http_status_codes": [
|
||||
400,
|
||||
@ -747,8 +747,8 @@
|
||||
"test_case_severity": "高",
|
||||
"status": "通过",
|
||||
"message": "跳过测试:在API规范中未找到合适的必填查询参数用于移除测试。",
|
||||
"duration_seconds": 0.016664458205923438,
|
||||
"timestamp": "2025-05-29T16:45:26.512857",
|
||||
"duration_seconds": 0.016721792053431273,
|
||||
"timestamp": "2025-06-05T13:18:24.457695",
|
||||
"validation_points": [
|
||||
{
|
||||
"passed": true,
|
||||
@ -762,9 +762,9 @@
|
||||
"endpoint_id": "GET /api/dms/{dms_instance_code}/v1/cd_geo_unit/{version}/{id}",
|
||||
"endpoint_name": "地质单元查询详情",
|
||||
"overall_status": "失败",
|
||||
"duration_seconds": 0.108735,
|
||||
"start_time": "2025-05-29T16:45:26.512925",
|
||||
"end_time": "2025-05-29T16:45:26.621660",
|
||||
"duration_seconds": 0.129312,
|
||||
"start_time": "2025-06-05T13:18:24.457925",
|
||||
"end_time": "2025-06-05T13:18:24.587237",
|
||||
"executed_test_cases": [
|
||||
{
|
||||
"test_case_id": "TC-STATUS-001",
|
||||
@ -772,8 +772,8 @@
|
||||
"test_case_severity": "严重",
|
||||
"status": "通过",
|
||||
"message": "响应状态码为 200,符合预期 200。",
|
||||
"duration_seconds": 0.020550667075440288,
|
||||
"timestamp": "2025-05-29T16:45:26.533561",
|
||||
"duration_seconds": 0.016316582914441824,
|
||||
"timestamp": "2025-06-05T13:18:24.474562",
|
||||
"validation_points": [
|
||||
{
|
||||
"passed": true,
|
||||
@ -787,8 +787,8 @@
|
||||
"test_case_severity": "严重",
|
||||
"status": "通过",
|
||||
"message": "针对 GET http://127.0.0.1:4523/m1/6389742-6086420-default/api/dms/example_dms_instance_code/v1/cd_geo_unit/1.0.0/example_id (状态码 200) 的响应体 conforms to the JSON schema.",
|
||||
"duration_seconds": 0.014473499963060021,
|
||||
"timestamp": "2025-05-29T16:45:26.548098",
|
||||
"duration_seconds": 0.017227750038728118,
|
||||
"timestamp": "2025-06-05T13:18:24.491939",
|
||||
"validation_points": [
|
||||
{
|
||||
"passed": true,
|
||||
@ -802,8 +802,8 @@
|
||||
"test_case_severity": "严重",
|
||||
"status": "失败",
|
||||
"message": "API通过HTTP (http://127.0.0.1:4523/m1/6389742-6086420-default/api/dms/example_dms_instance_code/v1/cd_geo_unit/1.0.0/example_id) 响应了成功的状态码 200,这违反了HTTPS强制策略。",
|
||||
"duration_seconds": 0.013600457925349474,
|
||||
"timestamp": "2025-05-29T16:45:26.561752",
|
||||
"duration_seconds": 0.021245625102892518,
|
||||
"timestamp": "2025-06-05T13:18:24.513427",
|
||||
"validation_points": [
|
||||
{
|
||||
"status_code": 200
|
||||
@ -816,8 +816,8 @@
|
||||
"test_case_severity": "中",
|
||||
"status": "通过",
|
||||
"message": "跳过测试:在查询参数中未找到合适的字段来测试类型不匹配。",
|
||||
"duration_seconds": 0.013701542047783732,
|
||||
"timestamp": "2025-05-29T16:45:26.575502",
|
||||
"duration_seconds": 0.016599917085841298,
|
||||
"timestamp": "2025-06-05T13:18:24.530175",
|
||||
"validation_points": [
|
||||
{
|
||||
"passed": true,
|
||||
@ -830,21 +830,21 @@
|
||||
"test_case_name": "Error Code 4001 - Request Body Type Mismatch Validation",
|
||||
"test_case_severity": "中",
|
||||
"status": "失败",
|
||||
"message": "当请求体字段 'isSearchCount' 类型不匹配时,期望API返回状态码在 [400, 422] 中,或返回4xx客户端错误且业务码为 '4001'. 实际收到状态码 200. 响应体中的业务码 ('code') 为 '70'.",
|
||||
"duration_seconds": 0.016377917025238276,
|
||||
"timestamp": "2025-05-29T16:45:26.591934",
|
||||
"message": "当请求体字段 'isSearchCount' 类型不匹配时,期望API返回状态码在 [400, 422] 中,或返回4xx客户端错误且业务码为 '4001'. 实际收到状态码 200. 响应体中的业务码 ('code') 为 '49'.",
|
||||
"duration_seconds": 0.02165700006298721,
|
||||
"timestamp": "2025-06-05T13:18:24.551889",
|
||||
"validation_points": [
|
||||
{
|
||||
"status_code": 200,
|
||||
"response_body": {
|
||||
"code": 70,
|
||||
"message": "amet labore ex",
|
||||
"code": 49,
|
||||
"message": "officia ex non do dolore",
|
||||
"data": {
|
||||
"total": 63,
|
||||
"total": 38,
|
||||
"list": [
|
||||
{
|
||||
"dsid": "44",
|
||||
"dataRegion": "eiusmod consequat irure Lorem do",
|
||||
"dsid": "22",
|
||||
"dataRegion": "do",
|
||||
"gasReleaseMon": null,
|
||||
"gasReleaseYear": null,
|
||||
"releaseGasCum": null
|
||||
@ -867,8 +867,8 @@
|
||||
"test_case_severity": "高",
|
||||
"status": "通过",
|
||||
"message": "跳过测试:在API规范中未找到合适的必填请求体字段用于移除测试。",
|
||||
"duration_seconds": 0.013865916058421135,
|
||||
"timestamp": "2025-05-29T16:45:26.605858",
|
||||
"duration_seconds": 0.015388375148177147,
|
||||
"timestamp": "2025-06-05T13:18:24.567343",
|
||||
"validation_points": [
|
||||
{
|
||||
"passed": true,
|
||||
@ -882,8 +882,8 @@
|
||||
"test_case_severity": "高",
|
||||
"status": "通过",
|
||||
"message": "跳过测试:在API规范中未找到合适的必填查询参数用于移除测试。",
|
||||
"duration_seconds": 0.015692499931901693,
|
||||
"timestamp": "2025-05-29T16:45:26.621611",
|
||||
"duration_seconds": 0.019689790904521942,
|
||||
"timestamp": "2025-06-05T13:18:24.587189",
|
||||
"validation_points": [
|
||||
{
|
||||
"passed": true,
|
||||
@ -893,5 +893,14 @@
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
],
|
||||
"stage_stats": {
|
||||
"total_defined": 0,
|
||||
"total_executed": 0,
|
||||
"passed": 0,
|
||||
"failed": 0,
|
||||
"skipped": 0,
|
||||
"success_rate_percentage": "0.00"
|
||||
},
|
||||
"detailed_stage_results": []
|
||||
}
|
||||
3351
test_reports/api_call_details.md
Normal file
3351
test_reports/api_call_details.md
Normal file
File diff suppressed because it is too large
Load Diff
940
test_reports/summary.json
Normal file
940
test_reports/summary.json
Normal file
@ -0,0 +1,940 @@
|
||||
{
|
||||
"summary_metadata": {
|
||||
"start_time": "2025-06-05T15:17:29.042083",
|
||||
"end_time": "2025-06-05T15:17:29.908800",
|
||||
"duration_seconds": "0.87"
|
||||
},
|
||||
"endpoint_stats": {
|
||||
"total_defined": 6,
|
||||
"total_tested": 6,
|
||||
"passed": 0,
|
||||
"failed": 6,
|
||||
"partial_success": 0,
|
||||
"error": 0,
|
||||
"skipped": 0,
|
||||
"success_rate_percentage": "0.00"
|
||||
},
|
||||
"test_case_stats": {
|
||||
"total_applicable": 42,
|
||||
"total_executed": 42,
|
||||
"passed": 24,
|
||||
"failed": 18,
|
||||
"error_in_execution": 0,
|
||||
"skipped_during_endpoint_execution": 0,
|
||||
"success_rate_percentage": "57.14"
|
||||
},
|
||||
"detailed_results": [
|
||||
{
|
||||
"endpoint_id": "POST /api/dms/{dms_instance_code}/v1/message/push/{schema}/{version}",
|
||||
"endpoint_name": "数据推送接口",
|
||||
"overall_status": "失败",
|
||||
"duration_seconds": 0.213524,
|
||||
"start_time": "2025-06-05T15:17:29.042420",
|
||||
"end_time": "2025-06-05T15:17:29.255944",
|
||||
"executed_test_cases": [
|
||||
{
|
||||
"test_case_id": "TC-STATUS-001",
|
||||
"test_case_name": "基本状态码 200 检查",
|
||||
"test_case_severity": "严重",
|
||||
"status": "通过",
|
||||
"message": "响应状态码为 200,符合预期 200。",
|
||||
"duration_seconds": 0.056339124916121364,
|
||||
"timestamp": "2025-06-05T15:17:29.098841",
|
||||
"validation_points": [
|
||||
{
|
||||
"passed": true,
|
||||
"message": "响应状态码为 200,符合预期 200。"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"test_case_id": "TC-CORE-FUNC-001",
|
||||
"test_case_name": "Response Body JSON Schema Validation",
|
||||
"test_case_severity": "严重",
|
||||
"status": "通过",
|
||||
"message": "Schema验证步骤完成(未发现问题,或schema不适用/未为此响应定义)。",
|
||||
"duration_seconds": 0.024997208965942264,
|
||||
"timestamp": "2025-06-05T15:17:29.123921",
|
||||
"validation_points": [
|
||||
{
|
||||
"passed": true,
|
||||
"message": "Schema验证步骤完成(未发现问题,或schema不适用/未为此响应定义)。"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"test_case_id": "TC-SECURITY-001",
|
||||
"test_case_name": "HTTPS Protocol Mandatory Verification",
|
||||
"test_case_severity": "严重",
|
||||
"status": "失败",
|
||||
"message": "API通过HTTP (http://127.0.0.1:4523/m1/6389742-6086420-default/api/dms/example_dms_instance_code/v1/message/push/example_schema/example_version) 响应了成功的状态码 200,这违反了HTTPS强制策略。",
|
||||
"duration_seconds": 0.01876020897179842,
|
||||
"timestamp": "2025-06-05T15:17:29.142762",
|
||||
"validation_points": [
|
||||
{
|
||||
"status_code": 200
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"test_case_id": "TC-ERROR-4001-QUERY",
|
||||
"test_case_name": "Error Code 4001 - Query Parameter Type Mismatch Validation",
|
||||
"test_case_severity": "中",
|
||||
"status": "通过",
|
||||
"message": "跳过测试:在查询参数中未找到合适的字段来测试类型不匹配。",
|
||||
"duration_seconds": 0.030960749834775925,
|
||||
"timestamp": "2025-06-05T15:17:29.173798",
|
||||
"validation_points": [
|
||||
{
|
||||
"passed": true,
|
||||
"message": "跳过测试:在查询参数中未找到合适的字段来测试类型不匹配。"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"test_case_id": "TC-ERROR-4001-BODY",
|
||||
"test_case_name": "Error Code 4001 - Request Body Type Mismatch Validation",
|
||||
"test_case_severity": "中",
|
||||
"status": "失败",
|
||||
"message": "当请求体字段 'isSearchCount' 类型不匹配时,期望API返回状态码在 [400, 422] 中,或返回4xx客户端错误且业务码为 '4001'. 实际收到状态码 200. 响应体中的业务码 ('code') 为 '49'.",
|
||||
"duration_seconds": 0.021089832996949553,
|
||||
"timestamp": "2025-06-05T15:17:29.194950",
|
||||
"validation_points": [
|
||||
{
|
||||
"status_code": 200,
|
||||
"response_body": {
|
||||
"code": 49,
|
||||
"message": "nulla sint",
|
||||
"data": {
|
||||
"total": 86,
|
||||
"list": [
|
||||
{
|
||||
"dsid": "54",
|
||||
"dataRegion": "laboris minim tempor",
|
||||
"gasReleaseMon": null,
|
||||
"gasReleaseYear": null,
|
||||
"releaseGasCum": null
|
||||
},
|
||||
{
|
||||
"dsid": "1",
|
||||
"dataRegion": "pariatur sed",
|
||||
"gasReleaseMon": null,
|
||||
"gasReleaseYear": null,
|
||||
"releaseGasCum": null
|
||||
},
|
||||
{
|
||||
"dsid": "100",
|
||||
"dataRegion": "dolor",
|
||||
"gasReleaseMon": null,
|
||||
"gasReleaseYear": null,
|
||||
"releaseGasCum": null
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"expected_http_status_codes": [
|
||||
400,
|
||||
422
|
||||
],
|
||||
"expected_business_code": "4001",
|
||||
"mismatched_field": "body.isSearchCount"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"test_case_id": "TC-ERROR-4003-BODY",
|
||||
"test_case_name": "Error Code 4003 - Missing Required Request Body Field Validation",
|
||||
"test_case_severity": "高",
|
||||
"status": "通过",
|
||||
"message": "跳过测试:在API规范中未找到合适的必填请求体字段用于移除测试。",
|
||||
"duration_seconds": 0.03394070896320045,
|
||||
"timestamp": "2025-06-05T15:17:29.228943",
|
||||
"validation_points": [
|
||||
{
|
||||
"passed": true,
|
||||
"message": "跳过测试:在API规范中未找到合适的必填请求体字段用于移除测试。"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"test_case_id": "TC-ERROR-4003-QUERY",
|
||||
"test_case_name": "Error Code 4003 - Missing Required Query Parameter Validation",
|
||||
"test_case_severity": "高",
|
||||
"status": "通过",
|
||||
"message": "跳过测试:在API规范中未找到合适的必填查询参数用于移除测试。",
|
||||
"duration_seconds": 0.02689095796085894,
|
||||
"timestamp": "2025-06-05T15:17:29.255896",
|
||||
"validation_points": [
|
||||
{
|
||||
"passed": true,
|
||||
"message": "跳过测试:在API规范中未找到合适的必填查询参数用于移除测试。"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"endpoint_id": "POST /api/dms/{dms_instance_code}/v1/cd_geo_unit/{version}",
|
||||
"endpoint_name": "地质单元列表查询",
|
||||
"overall_status": "失败",
|
||||
"duration_seconds": 0.131725,
|
||||
"start_time": "2025-06-05T15:17:29.255979",
|
||||
"end_time": "2025-06-05T15:17:29.387704",
|
||||
"executed_test_cases": [
|
||||
{
|
||||
"test_case_id": "TC-STATUS-001",
|
||||
"test_case_name": "基本状态码 200 检查",
|
||||
"test_case_severity": "严重",
|
||||
"status": "通过",
|
||||
"message": "响应状态码为 200,符合预期 200。",
|
||||
"duration_seconds": 0.018296750029549003,
|
||||
"timestamp": "2025-06-05T15:17:29.274382",
|
||||
"validation_points": [
|
||||
{
|
||||
"passed": true,
|
||||
"message": "响应状态码为 200,符合预期 200。"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"test_case_id": "TC-CORE-FUNC-001",
|
||||
"test_case_name": "Response Body JSON Schema Validation",
|
||||
"test_case_severity": "严重",
|
||||
"status": "通过",
|
||||
"message": "Schema验证步骤完成(未发现问题,或schema不适用/未为此响应定义)。",
|
||||
"duration_seconds": 0.018849125131964684,
|
||||
"timestamp": "2025-06-05T15:17:29.293302",
|
||||
"validation_points": [
|
||||
{
|
||||
"passed": true,
|
||||
"message": "Schema验证步骤完成(未发现问题,或schema不适用/未为此响应定义)。"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"test_case_id": "TC-SECURITY-001",
|
||||
"test_case_name": "HTTPS Protocol Mandatory Verification",
|
||||
"test_case_severity": "严重",
|
||||
"status": "失败",
|
||||
"message": "API通过HTTP (http://127.0.0.1:4523/m1/6389742-6086420-default/api/dms/example_dms_instance_code/v1/cd_geo_unit/1.0.0) 响应了成功的状态码 200,这违反了HTTPS强制策略。",
|
||||
"duration_seconds": 0.018643459072336555,
|
||||
"timestamp": "2025-06-05T15:17:29.312025",
|
||||
"validation_points": [
|
||||
{
|
||||
"status_code": 200
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"test_case_id": "TC-ERROR-4001-QUERY",
|
||||
"test_case_name": "Error Code 4001 - Query Parameter Type Mismatch Validation",
|
||||
"test_case_severity": "中",
|
||||
"status": "失败",
|
||||
"message": "当查询参数 'pageNo' (路径: 'pageNo') 类型不匹配时,期望API返回状态码在 [400, 422] 中,或返回4xx客户端错误且业务码为 '4001'. 实际收到状态码 200. 响应体中的业务码 ('code') 为 '77'.",
|
||||
"duration_seconds": 0.020573334069922566,
|
||||
"timestamp": "2025-06-05T15:17:29.332661",
|
||||
"validation_points": [
|
||||
{
|
||||
"status_code": 200,
|
||||
"response_body": {
|
||||
"code": 77,
|
||||
"message": "Ut",
|
||||
"data": {
|
||||
"total": 22,
|
||||
"list": [
|
||||
{
|
||||
"dsid": "46",
|
||||
"dataRegion": "elit commodo enim tempor in",
|
||||
"gasReleaseMon": null,
|
||||
"gasReleaseYear": null,
|
||||
"releaseGasCum": null
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"expected_http_status_codes": [
|
||||
400,
|
||||
422
|
||||
],
|
||||
"expected_business_code": "4001",
|
||||
"mismatched_param": "pageNo"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"test_case_id": "TC-ERROR-4001-BODY",
|
||||
"test_case_name": "Error Code 4001 - Request Body Type Mismatch Validation",
|
||||
"test_case_severity": "中",
|
||||
"status": "失败",
|
||||
"message": "当请求体字段 'isSearchCount' 类型不匹配时,期望API返回状态码在 [400, 422] 中,或返回4xx客户端错误且业务码为 '4001'. 实际收到状态码 200. 响应体中的业务码 ('code') 为 '43'.",
|
||||
"duration_seconds": 0.01727504190057516,
|
||||
"timestamp": "2025-06-05T15:17:29.349985",
|
||||
"validation_points": [
|
||||
{
|
||||
"status_code": 200,
|
||||
"response_body": {
|
||||
"code": 43,
|
||||
"message": "qui mollit nisi consectetur in",
|
||||
"data": {
|
||||
"total": 39,
|
||||
"list": [
|
||||
{
|
||||
"dsid": "98",
|
||||
"dataRegion": "commodo ad sed labore",
|
||||
"gasReleaseMon": null,
|
||||
"gasReleaseYear": null,
|
||||
"releaseGasCum": null
|
||||
},
|
||||
{
|
||||
"dsid": "28",
|
||||
"dataRegion": "laborum exercitation",
|
||||
"gasReleaseMon": null,
|
||||
"gasReleaseYear": null,
|
||||
"releaseGasCum": null
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"expected_http_status_codes": [
|
||||
400,
|
||||
422
|
||||
],
|
||||
"expected_business_code": "4001",
|
||||
"mismatched_field": "body.isSearchCount"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"test_case_id": "TC-ERROR-4003-BODY",
|
||||
"test_case_name": "Error Code 4003 - Missing Required Request Body Field Validation",
|
||||
"test_case_severity": "高",
|
||||
"status": "通过",
|
||||
"message": "跳过测试:在API规范中未找到合适的必填请求体字段用于移除测试。",
|
||||
"duration_seconds": 0.022129875142127275,
|
||||
"timestamp": "2025-06-05T15:17:29.372171",
|
||||
"validation_points": [
|
||||
{
|
||||
"passed": true,
|
||||
"message": "跳过测试:在API规范中未找到合适的必填请求体字段用于移除测试。"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"test_case_id": "TC-ERROR-4003-QUERY",
|
||||
"test_case_name": "Error Code 4003 - Missing Required Query Parameter Validation",
|
||||
"test_case_severity": "高",
|
||||
"status": "通过",
|
||||
"message": "跳过测试:在API规范中未找到合适的必填查询参数用于移除测试。",
|
||||
"duration_seconds": 0.015422499971464276,
|
||||
"timestamp": "2025-06-05T15:17:29.387659",
|
||||
"validation_points": [
|
||||
{
|
||||
"passed": true,
|
||||
"message": "跳过测试:在API规范中未找到合适的必填查询参数用于移除测试。"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"endpoint_id": "PUT /api/dms/{dms_instance_code}/v1/cd_geo_unit",
|
||||
"endpoint_name": "地质单元数据修改",
|
||||
"overall_status": "失败",
|
||||
"duration_seconds": 0.107461,
|
||||
"start_time": "2025-06-05T15:17:29.387737",
|
||||
"end_time": "2025-06-05T15:17:29.495198",
|
||||
"executed_test_cases": [
|
||||
{
|
||||
"test_case_id": "TC-STATUS-001",
|
||||
"test_case_name": "基本状态码 200 检查",
|
||||
"test_case_severity": "严重",
|
||||
"status": "通过",
|
||||
"message": "响应状态码为 200,符合预期 200。",
|
||||
"duration_seconds": 0.01535195903852582,
|
||||
"timestamp": "2025-06-05T15:17:29.403179",
|
||||
"validation_points": [
|
||||
{
|
||||
"passed": true,
|
||||
"message": "响应状态码为 200,符合预期 200。"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"test_case_id": "TC-CORE-FUNC-001",
|
||||
"test_case_name": "Response Body JSON Schema Validation",
|
||||
"test_case_severity": "严重",
|
||||
"status": "通过",
|
||||
"message": "针对 PUT http://127.0.0.1:4523/m1/6389742-6086420-default/api/dms/example_dms_instance_code/v1/cd_geo_unit (状态码 200) 的响应体 conforms to the JSON schema.",
|
||||
"duration_seconds": 0.018150458810850978,
|
||||
"timestamp": "2025-06-05T15:17:29.421384",
|
||||
"validation_points": [
|
||||
{
|
||||
"passed": true,
|
||||
"message": "针对 PUT http://127.0.0.1:4523/m1/6389742-6086420-default/api/dms/example_dms_instance_code/v1/cd_geo_unit (状态码 200) 的响应体 conforms to the JSON schema."
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"test_case_id": "TC-SECURITY-001",
|
||||
"test_case_name": "HTTPS Protocol Mandatory Verification",
|
||||
"test_case_severity": "严重",
|
||||
"status": "失败",
|
||||
"message": "API通过HTTP (http://127.0.0.1:4523/m1/6389742-6086420-default/api/dms/example_dms_instance_code/v1/cd_geo_unit) 响应了成功的状态码 200,这违反了HTTPS强制策略。",
|
||||
"duration_seconds": 0.014581916853785515,
|
||||
"timestamp": "2025-06-05T15:17:29.436038",
|
||||
"validation_points": [
|
||||
{
|
||||
"status_code": 200
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"test_case_id": "TC-ERROR-4001-QUERY",
|
||||
"test_case_name": "Error Code 4001 - Query Parameter Type Mismatch Validation",
|
||||
"test_case_severity": "中",
|
||||
"status": "失败",
|
||||
"message": "当查询参数 'id' (路径: 'id') 类型不匹配时,期望API返回状态码在 [400, 422] 中,或返回4xx客户端错误且业务码为 '4001'. 实际收到状态码 200. 响应体中的业务码 ('code') 为 '6'.",
|
||||
"duration_seconds": 0.014280792092904449,
|
||||
"timestamp": "2025-06-05T15:17:29.450371",
|
||||
"validation_points": [
|
||||
{
|
||||
"status_code": 200,
|
||||
"response_body": {
|
||||
"code": 6,
|
||||
"message": "consequat fugiat Duis nostrud",
|
||||
"data": false
|
||||
},
|
||||
"expected_http_status_codes": [
|
||||
400,
|
||||
422
|
||||
],
|
||||
"expected_business_code": "4001",
|
||||
"mismatched_param": "id"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"test_case_id": "TC-ERROR-4001-BODY",
|
||||
"test_case_name": "Error Code 4001 - Request Body Type Mismatch Validation",
|
||||
"test_case_severity": "中",
|
||||
"status": "失败",
|
||||
"message": "当请求体字段 'id' 类型不匹配时,期望API返回状态码在 [400, 422] 中,或返回4xx客户端错误且业务码为 '4001'. 实际收到状态码 200. 响应体中的业务码 ('code') 为 '2'.",
|
||||
"duration_seconds": 0.014196124859154224,
|
||||
"timestamp": "2025-06-05T15:17:29.464613",
|
||||
"validation_points": [
|
||||
{
|
||||
"status_code": 200,
|
||||
"response_body": {
|
||||
"code": 2,
|
||||
"message": "non",
|
||||
"data": false
|
||||
},
|
||||
"expected_http_status_codes": [
|
||||
400,
|
||||
422
|
||||
],
|
||||
"expected_business_code": "4001",
|
||||
"mismatched_field": "body.id"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"test_case_id": "TC-ERROR-4003-BODY",
|
||||
"test_case_name": "Error Code 4003 - Missing Required Request Body Field Validation",
|
||||
"test_case_severity": "高",
|
||||
"status": "通过",
|
||||
"message": "当移除必填请求体字段 'id' 时,API响应了状态码 200 (非主要预期HTTP状态 [400, 422],但为4xx客户端错误), 且响应体中包含预期的业务错误码 '4003' (字段: 'code').",
|
||||
"duration_seconds": 0.01675216620787978,
|
||||
"timestamp": "2025-06-05T15:17:29.481407",
|
||||
"validation_points": [
|
||||
{
|
||||
"passed": true,
|
||||
"message": "当移除必填请求体字段 'id' 时,API响应了状态码 200 (非主要预期HTTP状态 [400, 422],但为4xx客户端错误), 且响应体中包含预期的业务错误码 '4003' (字段: 'code')."
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"test_case_id": "TC-ERROR-4003-QUERY",
|
||||
"test_case_name": "Error Code 4003 - Missing Required Query Parameter Validation",
|
||||
"test_case_severity": "高",
|
||||
"status": "失败",
|
||||
"message": "当移除必填查询参数 'id' 时,期望API返回状态码在 [400, 422] 中,或返回4xx客户端错误且业务码为 '4003'. 实际收到状态码 200. 响应体中的业务码 ('code') 为 '26'.",
|
||||
"duration_seconds": 0.01370533392764628,
|
||||
"timestamp": "2025-06-05T15:17:29.495158",
|
||||
"validation_points": [
|
||||
{
|
||||
"status_code": 200,
|
||||
"response_body": {
|
||||
"code": 26,
|
||||
"message": "in",
|
||||
"data": false
|
||||
},
|
||||
"expected_http_status_codes": [
|
||||
400,
|
||||
422
|
||||
],
|
||||
"expected_business_code": "4003",
|
||||
"removed_param": "query.id"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"endpoint_id": "DELETE /api/dms/{dms_instance_code}/v1/cd_geo_unit",
|
||||
"endpoint_name": "地质单元数据删除",
|
||||
"overall_status": "失败",
|
||||
"duration_seconds": 0.1148,
|
||||
"start_time": "2025-06-05T15:17:29.495226",
|
||||
"end_time": "2025-06-05T15:17:29.610026",
|
||||
"executed_test_cases": [
|
||||
{
|
||||
"test_case_id": "TC-STATUS-001",
|
||||
"test_case_name": "基本状态码 200 检查",
|
||||
"test_case_severity": "严重",
|
||||
"status": "通过",
|
||||
"message": "响应状态码为 200,符合预期 200。",
|
||||
"duration_seconds": 0.0190741668920964,
|
||||
"timestamp": "2025-06-05T15:17:29.514381",
|
||||
"validation_points": [
|
||||
{
|
||||
"passed": true,
|
||||
"message": "响应状态码为 200,符合预期 200。"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"test_case_id": "TC-CORE-FUNC-001",
|
||||
"test_case_name": "Response Body JSON Schema Validation",
|
||||
"test_case_severity": "严重",
|
||||
"status": "通过",
|
||||
"message": "针对 DELETE http://127.0.0.1:4523/m1/6389742-6086420-default/api/dms/example_dms_instance_code/v1/cd_geo_unit (状态码 200) 的响应体 conforms to the JSON schema.",
|
||||
"duration_seconds": 0.014373708050698042,
|
||||
"timestamp": "2025-06-05T15:17:29.528807",
|
||||
"validation_points": [
|
||||
{
|
||||
"passed": true,
|
||||
"message": "针对 DELETE http://127.0.0.1:4523/m1/6389742-6086420-default/api/dms/example_dms_instance_code/v1/cd_geo_unit (状态码 200) 的响应体 conforms to the JSON schema."
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"test_case_id": "TC-SECURITY-001",
|
||||
"test_case_name": "HTTPS Protocol Mandatory Verification",
|
||||
"test_case_severity": "严重",
|
||||
"status": "失败",
|
||||
"message": "API通过HTTP (http://127.0.0.1:4523/m1/6389742-6086420-default/api/dms/example_dms_instance_code/v1/cd_geo_unit) 响应了成功的状态码 200,这违反了HTTPS强制策略。",
|
||||
"duration_seconds": 0.01420162501744926,
|
||||
"timestamp": "2025-06-05T15:17:29.543058",
|
||||
"validation_points": [
|
||||
{
|
||||
"status_code": 200
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"test_case_id": "TC-ERROR-4001-QUERY",
|
||||
"test_case_name": "Error Code 4001 - Query Parameter Type Mismatch Validation",
|
||||
"test_case_severity": "中",
|
||||
"status": "失败",
|
||||
"message": "当查询参数 'id' (路径: 'id') 类型不匹配时,期望API返回状态码在 [400, 422] 中,或返回4xx客户端错误且业务码为 '4001'. 实际收到状态码 200. 响应体中的业务码 ('code') 为 '11'.",
|
||||
"duration_seconds": 0.01511041703633964,
|
||||
"timestamp": "2025-06-05T15:17:29.558213",
|
||||
"validation_points": [
|
||||
{
|
||||
"status_code": 200,
|
||||
"response_body": {
|
||||
"code": 11,
|
||||
"message": "et cillum irure",
|
||||
"data": false
|
||||
},
|
||||
"expected_http_status_codes": [
|
||||
400,
|
||||
422
|
||||
],
|
||||
"expected_business_code": "4001",
|
||||
"mismatched_param": "id"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"test_case_id": "TC-ERROR-4001-BODY",
|
||||
"test_case_name": "Error Code 4001 - Request Body Type Mismatch Validation",
|
||||
"test_case_severity": "中",
|
||||
"status": "失败",
|
||||
"message": "当请求体字段 'version' 类型不匹配时,期望API返回状态码在 [400, 422] 中,或返回4xx客户端错误且业务码为 '4001'. 实际收到状态码 200. 响应体中的业务码 ('code') 为 '80'.",
|
||||
"duration_seconds": 0.016268166014924645,
|
||||
"timestamp": "2025-06-05T15:17:29.574524",
|
||||
"validation_points": [
|
||||
{
|
||||
"status_code": 200,
|
||||
"response_body": {
|
||||
"code": 80,
|
||||
"message": "laborum",
|
||||
"data": true
|
||||
},
|
||||
"expected_http_status_codes": [
|
||||
400,
|
||||
422
|
||||
],
|
||||
"expected_business_code": "4001",
|
||||
"mismatched_field": "body.version"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"test_case_id": "TC-ERROR-4003-BODY",
|
||||
"test_case_name": "Error Code 4003 - Missing Required Request Body Field Validation",
|
||||
"test_case_severity": "高",
|
||||
"status": "通过",
|
||||
"message": "跳过测试:在API规范中未找到合适的必填请求体字段用于移除测试。",
|
||||
"duration_seconds": 0.015059833182021976,
|
||||
"timestamp": "2025-06-05T15:17:29.589636",
|
||||
"validation_points": [
|
||||
{
|
||||
"passed": true,
|
||||
"message": "跳过测试:在API规范中未找到合适的必填请求体字段用于移除测试。"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"test_case_id": "TC-ERROR-4003-QUERY",
|
||||
"test_case_name": "Error Code 4003 - Missing Required Query Parameter Validation",
|
||||
"test_case_severity": "高",
|
||||
"status": "失败",
|
||||
"message": "当移除必填查询参数 'id' 时,期望API返回状态码在 [400, 422] 中,或返回4xx客户端错误且业务码为 '4003'. 实际收到状态码 200. 响应体中的业务码 ('code') 为 '63'.",
|
||||
"duration_seconds": 0.020070707891136408,
|
||||
"timestamp": "2025-06-05T15:17:29.609887",
|
||||
"validation_points": [
|
||||
{
|
||||
"status_code": 200,
|
||||
"response_body": {
|
||||
"code": 63,
|
||||
"message": "esse",
|
||||
"data": true
|
||||
},
|
||||
"expected_http_status_codes": [
|
||||
400,
|
||||
422
|
||||
],
|
||||
"expected_business_code": "4003",
|
||||
"removed_param": "query.id"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"endpoint_id": "POST /api/dms/{dms_instance_code}/v1/cd_geo_unit",
|
||||
"endpoint_name": "地质单元数据添加",
|
||||
"overall_status": "失败",
|
||||
"duration_seconds": 0.120808,
|
||||
"start_time": "2025-06-05T15:17:29.610139",
|
||||
"end_time": "2025-06-05T15:17:29.730947",
|
||||
"executed_test_cases": [
|
||||
{
|
||||
"test_case_id": "TC-STATUS-001",
|
||||
"test_case_name": "基本状态码 200 检查",
|
||||
"test_case_severity": "严重",
|
||||
"status": "通过",
|
||||
"message": "响应状态码为 200,符合预期 200。",
|
||||
"duration_seconds": 0.015151500003412366,
|
||||
"timestamp": "2025-06-05T15:17:29.625592",
|
||||
"validation_points": [
|
||||
{
|
||||
"passed": true,
|
||||
"message": "响应状态码为 200,符合预期 200。"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"test_case_id": "TC-CORE-FUNC-001",
|
||||
"test_case_name": "Response Body JSON Schema Validation",
|
||||
"test_case_severity": "严重",
|
||||
"status": "通过",
|
||||
"message": "Schema验证步骤完成(未发现问题,或schema不适用/未为此响应定义)。",
|
||||
"duration_seconds": 0.016777874901890755,
|
||||
"timestamp": "2025-06-05T15:17:29.642530",
|
||||
"validation_points": [
|
||||
{
|
||||
"passed": true,
|
||||
"message": "Schema验证步骤完成(未发现问题,或schema不适用/未为此响应定义)。"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"test_case_id": "TC-SECURITY-001",
|
||||
"test_case_name": "HTTPS Protocol Mandatory Verification",
|
||||
"test_case_severity": "严重",
|
||||
"status": "失败",
|
||||
"message": "API通过HTTP (http://127.0.0.1:4523/m1/6389742-6086420-default/api/dms/example_dms_instance_code/v1/cd_geo_unit) 响应了成功的状态码 200,这违反了HTTPS强制策略。",
|
||||
"duration_seconds": 0.015054750023409724,
|
||||
"timestamp": "2025-06-05T15:17:29.657759",
|
||||
"validation_points": [
|
||||
{
|
||||
"status_code": 200
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"test_case_id": "TC-ERROR-4001-QUERY",
|
||||
"test_case_name": "Error Code 4001 - Query Parameter Type Mismatch Validation",
|
||||
"test_case_severity": "中",
|
||||
"status": "通过",
|
||||
"message": "跳过测试:在查询参数中未找到合适的字段来测试类型不匹配。",
|
||||
"duration_seconds": 0.018453167052939534,
|
||||
"timestamp": "2025-06-05T15:17:29.676411",
|
||||
"validation_points": [
|
||||
{
|
||||
"passed": true,
|
||||
"message": "跳过测试:在查询参数中未找到合适的字段来测试类型不匹配。"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"test_case_id": "TC-ERROR-4001-BODY",
|
||||
"test_case_name": "Error Code 4001 - Request Body Type Mismatch Validation",
|
||||
"test_case_severity": "中",
|
||||
"status": "失败",
|
||||
"message": "当请求体字段 'version' 类型不匹配时,期望API返回状态码在 [400, 422] 中,或返回4xx客户端错误且业务码为 '4001'. 实际收到状态码 200. 响应体中的业务码 ('code') 为 '10'.",
|
||||
"duration_seconds": 0.01660362514667213,
|
||||
"timestamp": "2025-06-05T15:17:29.693183",
|
||||
"validation_points": [
|
||||
{
|
||||
"status_code": 200,
|
||||
"response_body": {
|
||||
"code": 10,
|
||||
"message": "anim laborum enim incididunt sed",
|
||||
"data": false
|
||||
},
|
||||
"expected_http_status_codes": [
|
||||
400,
|
||||
422
|
||||
],
|
||||
"expected_business_code": "4001",
|
||||
"mismatched_field": "body.version"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"test_case_id": "TC-ERROR-4003-BODY",
|
||||
"test_case_name": "Error Code 4003 - Missing Required Request Body Field Validation",
|
||||
"test_case_severity": "高",
|
||||
"status": "失败",
|
||||
"message": "当移除必填请求体字段 'data.0.bsflag' 时,期望API返回状态码在 [400, 422] 中,或返回4xx客户端错误且业务码为 '4003'. 实际收到状态码 200. 响应体中的业务码 ('code') 为 '16'.",
|
||||
"duration_seconds": 0.020117375068366528,
|
||||
"timestamp": "2025-06-05T15:17:29.713519",
|
||||
"validation_points": [
|
||||
{
|
||||
"status_code": 200,
|
||||
"response_body": {
|
||||
"code": 16,
|
||||
"message": "aliqua",
|
||||
"data": false
|
||||
},
|
||||
"expected_http_status_codes": [
|
||||
400,
|
||||
422
|
||||
],
|
||||
"expected_business_code": "4003",
|
||||
"removed_field": "body.data.0.bsflag"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"test_case_id": "TC-ERROR-4003-QUERY",
|
||||
"test_case_name": "Error Code 4003 - Missing Required Query Parameter Validation",
|
||||
"test_case_severity": "高",
|
||||
"status": "通过",
|
||||
"message": "跳过测试:在API规范中未找到合适的必填查询参数用于移除测试。",
|
||||
"duration_seconds": 0.017241874942556024,
|
||||
"timestamp": "2025-06-05T15:17:29.730904",
|
||||
"validation_points": [
|
||||
{
|
||||
"passed": true,
|
||||
"message": "跳过测试:在API规范中未找到合适的必填查询参数用于移除测试。"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"endpoint_id": "GET /api/dms/{dms_instance_code}/v1/cd_geo_unit/{version}/{id}",
|
||||
"endpoint_name": "地质单元查询详情",
|
||||
"overall_status": "失败",
|
||||
"duration_seconds": 0.111602,
|
||||
"start_time": "2025-06-05T15:17:29.730976",
|
||||
"end_time": "2025-06-05T15:17:29.842578",
|
||||
"executed_test_cases": [
|
||||
{
|
||||
"test_case_id": "TC-STATUS-001",
|
||||
"test_case_name": "基本状态码 200 检查",
|
||||
"test_case_severity": "严重",
|
||||
"status": "通过",
|
||||
"message": "响应状态码为 200,符合预期 200。",
|
||||
"duration_seconds": 0.015448833117261529,
|
||||
"timestamp": "2025-06-05T15:17:29.746516",
|
||||
"validation_points": [
|
||||
{
|
||||
"passed": true,
|
||||
"message": "响应状态码为 200,符合预期 200。"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"test_case_id": "TC-CORE-FUNC-001",
|
||||
"test_case_name": "Response Body JSON Schema Validation",
|
||||
"test_case_severity": "严重",
|
||||
"status": "通过",
|
||||
"message": "针对 GET http://127.0.0.1:4523/m1/6389742-6086420-default/api/dms/example_dms_instance_code/v1/cd_geo_unit/1.0.0/example_id (状态码 200) 的响应体 conforms to the JSON schema.",
|
||||
"duration_seconds": 0.01599195785820484,
|
||||
"timestamp": "2025-06-05T15:17:29.762571",
|
||||
"validation_points": [
|
||||
{
|
||||
"passed": true,
|
||||
"message": "针对 GET http://127.0.0.1:4523/m1/6389742-6086420-default/api/dms/example_dms_instance_code/v1/cd_geo_unit/1.0.0/example_id (状态码 200) 的响应体 conforms to the JSON schema."
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"test_case_id": "TC-SECURITY-001",
|
||||
"test_case_name": "HTTPS Protocol Mandatory Verification",
|
||||
"test_case_severity": "严重",
|
||||
"status": "失败",
|
||||
"message": "API通过HTTP (http://127.0.0.1:4523/m1/6389742-6086420-default/api/dms/example_dms_instance_code/v1/cd_geo_unit/1.0.0/example_id) 响应了成功的状态码 200,这违反了HTTPS强制策略。",
|
||||
"duration_seconds": 0.014472666895017028,
|
||||
"timestamp": "2025-06-05T15:17:29.777102",
|
||||
"validation_points": [
|
||||
{
|
||||
"status_code": 200
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"test_case_id": "TC-ERROR-4001-QUERY",
|
||||
"test_case_name": "Error Code 4001 - Query Parameter Type Mismatch Validation",
|
||||
"test_case_severity": "中",
|
||||
"status": "通过",
|
||||
"message": "跳过测试:在查询参数中未找到合适的字段来测试类型不匹配。",
|
||||
"duration_seconds": 0.016666082898154855,
|
||||
"timestamp": "2025-06-05T15:17:29.793836",
|
||||
"validation_points": [
|
||||
{
|
||||
"passed": true,
|
||||
"message": "跳过测试:在查询参数中未找到合适的字段来测试类型不匹配。"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"test_case_id": "TC-ERROR-4001-BODY",
|
||||
"test_case_name": "Error Code 4001 - Request Body Type Mismatch Validation",
|
||||
"test_case_severity": "中",
|
||||
"status": "失败",
|
||||
"message": "当请求体字段 'isSearchCount' 类型不匹配时,期望API返回状态码在 [400, 422] 中,或返回4xx客户端错误且业务码为 '4001'. 实际收到状态码 200. 响应体中的业务码 ('code') 为 '11'.",
|
||||
"duration_seconds": 0.019235124811530113,
|
||||
"timestamp": "2025-06-05T15:17:29.813135",
|
||||
"validation_points": [
|
||||
{
|
||||
"status_code": 200,
|
||||
"response_body": {
|
||||
"code": 11,
|
||||
"message": "ea dolor",
|
||||
"data": {
|
||||
"total": 54,
|
||||
"list": [
|
||||
{
|
||||
"dsid": "99",
|
||||
"dataRegion": "Excepteur sunt veniam",
|
||||
"gasReleaseMon": null,
|
||||
"gasReleaseYear": null,
|
||||
"releaseGasCum": null
|
||||
},
|
||||
{
|
||||
"dsid": "32",
|
||||
"dataRegion": "Lorem amet laboris non",
|
||||
"gasReleaseMon": null,
|
||||
"gasReleaseYear": null,
|
||||
"releaseGasCum": null
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"expected_http_status_codes": [
|
||||
400,
|
||||
422
|
||||
],
|
||||
"expected_business_code": "4001",
|
||||
"mismatched_field": "body.isSearchCount"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"test_case_id": "TC-ERROR-4003-BODY",
|
||||
"test_case_name": "Error Code 4003 - Missing Required Request Body Field Validation",
|
||||
"test_case_severity": "高",
|
||||
"status": "通过",
|
||||
"message": "跳过测试:在API规范中未找到合适的必填请求体字段用于移除测试。",
|
||||
"duration_seconds": 0.014753791969269514,
|
||||
"timestamp": "2025-06-05T15:17:29.827943",
|
||||
"validation_points": [
|
||||
{
|
||||
"passed": true,
|
||||
"message": "跳过测试:在API规范中未找到合适的必填请求体字段用于移除测试。"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"test_case_id": "TC-ERROR-4003-QUERY",
|
||||
"test_case_name": "Error Code 4003 - Missing Required Query Parameter Validation",
|
||||
"test_case_severity": "高",
|
||||
"status": "通过",
|
||||
"message": "跳过测试:在API规范中未找到合适的必填查询参数用于移除测试。",
|
||||
"duration_seconds": 0.014537167036905885,
|
||||
"timestamp": "2025-06-05T15:17:29.842539",
|
||||
"validation_points": [
|
||||
{
|
||||
"passed": true,
|
||||
"message": "跳过测试:在API规范中未找到合适的必填查询参数用于移除测试。"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"stage_stats": {
|
||||
"total_defined": 1,
|
||||
"total_executed": 2,
|
||||
"passed": 0,
|
||||
"failed": 2,
|
||||
"skipped": 0,
|
||||
"success_rate_percentage": "0.00"
|
||||
},
|
||||
"detailed_stage_results": [
|
||||
{
|
||||
"stage_id": "keyword_driven_crud_example",
|
||||
"stage_name": "Keyword-Driven Generic CRUD Stage Example",
|
||||
"description": "Demonstrates a CRUD (Create, List, Read, Update, Delete) flow. This stage dynamically finds API operations based on a configurable RESOURCE_KEYWORD and predefined action keywords (e.g., '添加', '查询列表'). IMPORTANT: User MUST configure RESOURCE_KEYWORD at the top of this file. Request bodies, response paths in assertions, and 'outputs_to_context' are EXAMPLES based on '井筒API示例_simple.json' and WILL LIKELY NEED MODIFICATION to match your specific API's structure.",
|
||||
"api_group_name": "公共分类",
|
||||
"overall_status": "失败",
|
||||
"duration_seconds": "0.03",
|
||||
"start_time": "2025-06-05T15:17:29+0800",
|
||||
"end_time": "2025-06-05T15:17:29+0800",
|
||||
"message": "Stage aborted due to failure in step 'List and Find Created 地质单元'.",
|
||||
"executed_steps_count": 0,
|
||||
"executed_steps": []
|
||||
},
|
||||
{
|
||||
"stage_id": "keyword_driven_crud_example",
|
||||
"stage_name": "Keyword-Driven Generic CRUD Stage Example",
|
||||
"description": "Demonstrates a CRUD (Create, List, Read, Update, Delete) flow. This stage dynamically finds API operations based on a configurable RESOURCE_KEYWORD and predefined action keywords (e.g., '添加', '查询列表'). IMPORTANT: User MUST configure RESOURCE_KEYWORD at the top of this file. Request bodies, response paths in assertions, and 'outputs_to_context' are EXAMPLES based on '井筒API示例_simple.json' and WILL LIKELY NEED MODIFICATION to match your specific API's structure.",
|
||||
"api_group_name": "地质单元",
|
||||
"overall_status": "失败",
|
||||
"duration_seconds": "0.04",
|
||||
"start_time": "2025-06-05T15:17:29+0800",
|
||||
"end_time": "2025-06-05T15:17:29+0800",
|
||||
"message": "Stage aborted due to failure in step 'List and Find Created 地质单元'.",
|
||||
"executed_steps_count": 0,
|
||||
"executed_steps": []
|
||||
}
|
||||
]
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user