step half finish

This commit is contained in:
gongwenxin 2025-06-05 15:17:51 +08:00
parent e23f2856d6
commit 7333cc8a2a
58 changed files with 11351 additions and 4788 deletions

BIN
.DS_Store vendored

Binary file not shown.

View File

@ -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"

File diff suppressed because it is too large Load Diff

View 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}")

View File

@ -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

View 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)

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

View 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: 状态保持为初始的 SKIPPEDmessage也应该在之前设置了
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 # 可选: 输出处理后的上下文摘要
}

View 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 (重载时): 未配置阶段目录,没有加载任何自定义阶段。")

View File

@ -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}')"

View File

@ -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
View 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)

3020
log.txt

File diff suppressed because it is too large Load Diff

2660
log_stage.txt Normal file

File diff suppressed because it is too large Load Diff

View File

@ -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
View 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
View 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
View 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;
}

View File

@ -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": []
}

File diff suppressed because it is too large Load Diff

940
test_reports/summary.json Normal file
View 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": []
}
]
}