compliance/ddms_compliance_suite/scenario_framework.py
2025-06-05 15:17:51 +08:00

228 lines
12 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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)