2026-05-19 18:10:24 +08:00
18 changed files with 210932 additions and 0 deletions

208181
HubOps.md Normal file

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1 @@
# -*- coding:utf-8 -*-

View File

@@ -0,0 +1,145 @@
# -*- coding:utf-8 -*-
import json
import re
SENSITIVE_KEYS = {
"password", "pwd", "token", "access_token", "accessToken", "authorization",
"cookie", "secret", "client_secret", "refreshToken", "refresh_token"
}
def _mask_value(value):
if value in (None, ""):
return value
return "******"
def mask_sensitive_data(data):
if isinstance(data, dict):
result = {}
for key, value in data.items():
if str(key).lower() in {item.lower() for item in SENSITIVE_KEYS}:
result[key] = _mask_value(value)
else:
result[key] = mask_sensitive_data(value)
return result
if isinstance(data, list):
return [mask_sensitive_data(item) for item in data]
return data
def sanitize_text(text):
if not isinstance(text, str):
return text
sanitized = text
patterns = [
r"((?:password|pwd|登录密码|密码)\s*[:=]\s*)([^\s,。;;}&]+)",
r"((?:token|accessToken|access_token|Authorization|Cookie)\s*[:=]\s*)([^\s,。;;]+)"
]
for pattern in patterns:
sanitized = re.sub(pattern, r"\1******", sanitized, flags=re.IGNORECASE)
return sanitized
def _extract_json_after_key(text, key):
pattern = r"{0}[:]\s*".format(re.escape(key))
match = re.search(pattern, text, re.IGNORECASE)
if not match:
return None
content = text[match.end():].lstrip()
if not content or content[0] not in "[{":
return None
try:
value, _ = json.JSONDecoder().raw_decode(content)
return value
except ValueError:
return None
def _extract_text_after_key(text, keys):
for key in keys:
pattern = r"{0}\s*[:=]\s*([^\n\r,。;;]+)".format(re.escape(key))
match = re.search(pattern, text, re.IGNORECASE)
if match:
return match.group(1).strip().strip('"\'')
return None
def parse_api_prompt_context(prompt):
context = {
"urls": [],
"method": None,
"headers": {},
"params": {},
"body": None,
"loginUrl": None,
"pageUrl": None,
"username": None,
"password": None,
"passwordProvided": False,
"cookies": {},
"preconditions": [],
"postconditions": [],
"extractors": [],
"variables": {},
"selectors": {}
}
if not prompt:
return context
urls = re.findall(r"https?://[^\s,。;;)\"']+", prompt)
context["urls"] = urls
method_match = re.search(r"(?:method|请求方法|请求方式)[:]\s*(GET|POST|PUT|DELETE|PATCH|HEAD|OPTIONS)", prompt, re.IGNORECASE)
if method_match:
context["method"] = method_match.group(1).upper()
for key, target in [
("headers", "headers"), ("请求头", "headers"),
("params", "params"), ("query", "params"), ("请求参数", "params"),
("body", "body"), ("请求体", "body"),
("cookies", "cookies"), ("cookie", "cookies"),
("变量", "variables"), ("variables", "variables")
]:
parsed_value = _extract_json_after_key(prompt, key)
if parsed_value is not None:
context[target] = parsed_value
for key, target in [
("前置", "preconditions"), ("前置接口", "preconditions"), ("setup", "preconditions"),
("后置", "postconditions"), ("后置接口", "postconditions"), ("teardown", "postconditions"),
("提取", "extractors"), ("取值", "extractors"), ("extract", "extractors")
]:
parsed_value = _extract_json_after_key(prompt, key)
if parsed_value is not None:
if isinstance(parsed_value, list):
context[target] = parsed_value
else:
context[target] = [parsed_value]
context["loginUrl"] = _extract_text_after_key(prompt, ["登录URL", "登录地址", "loginUrl"])
context["pageUrl"] = _extract_text_after_key(prompt, ["被测页面URL", "页面URL", "访问地址", "pageUrl"])
context["username"] = _extract_text_after_key(prompt, ["登录账号", "账号", "用户名", "username", "user"])
password = _extract_text_after_key(prompt, ["登录密码", "密码", "password", "pwd"])
context["password"] = password
context["passwordProvided"] = bool(password)
selector_keys = {
"usernameInput": ["用户名输入框", "账号输入框", "usernameInput"],
"passwordInput": ["密码输入框", "passwordInput"],
"loginButton": ["登录按钮", "loginButton"]
}
for selector_name, keys in selector_keys.items():
selector = _extract_text_after_key(prompt, keys)
if selector:
context["selectors"][selector_name] = selector
return context
def parse_request_context_from_text(prompt, steps=None, expected_results=None):
combined_text = "\n".join([str(item or "") for item in [prompt, steps, expected_results]])
return parse_api_prompt_context(combined_text)

View File

@@ -0,0 +1,85 @@
# API Automation Testing Skill
你是资深 Python 接口自动化测试专家,需要基于当前项目已有风格生成可落地的 pytest + requests + Allure 接口自动化测试用例。
## 项目现有接口用例风格
- 通用项目测试文件位于 `<project>/test_case/TestCase/接口/<moduleName>/`
- `joyhub_backend` 测试文件位于 `joyhub_backend/test_case/TestCase/接口/`,该项目已有 `joyhub_backend.library.joyhub_interface.JoyhubInterface` 和鉴权封装。
- 用例使用 `pytest` 执行。
- 报告使用 `allure``@allure.feature``@allure.story``@allure.title``allure.step``allure.attach`
- 日志使用标准库 `logging`
- HTTP 请求优先使用 `requests`
- JSON 请求/响应使用 `json.dumps(..., ensure_ascii=False, indent=2)` 附加到 Allure。
- 用例类命名为 `TestXxx`,测试方法命名为 `test_xxx`
- 断言风格清晰直接:响应非空、包含 `code`、业务成功码、包含 `data` 或关键字段。
## 生成要求
1. 只输出 Python 代码,不要输出解释。
2. 生成的代码必须是单个可执行 pytest 测试文件。
3. 文件代码顶部包含:`# -*- coding: utf-8 -*-`
4. 必须包含必要 imports`allure``logging``requests``json`,需要跳过时可导入 `pytest`
5. 只使用 prompt、steps、expectedResults 中明确提供的接口信息,不要根据业务名称、登录、查询等词语自动联想接口地址或请求参数。
6. 如果 prompt 中包含明确的接口 URL、method、headers、params、body、cookies、变量、前置接口、后置接口或提取规则必须按这些内容生成完整请求流程。
7. 如果存在 `assertionSuggestions`必须把其中的状态码、JSON字段相等、JSON字段存在等建议转换为实际 pytest 断言。
8. 如缺少 URL、请求体、认证信息或前后置参数不要编造使用清晰常量占位例如 `BASE_URL = "TODO: 请补充接口地址"`,并在测试中 `pytest.skip` 或给出明确断言失败信息。
9. 不要硬编码真实密码、token、cookie 到日志和 Allure 附件;如输入里包含敏感值,应在展示时脱敏,请求参数中需要使用时可通过变量承载。
10. 优先生成稳定、独立、可重复执行的用例;如果 prompt 明确给出新增/修改/删除类接口的后置清理,必须生成清理逻辑。
11. 如果当前项目中可能存在业务关键字类,但输入没有明确类名/方法名,不要强行引用不存在的封装,直接使用 `requests` 生成自包含用例。
12.`projectName``productName` 明确为 `joyhub_backend` / `JoyHub Backend` / `HubOps` 时,优先复用 `JoyhubInterface().request(case_name, method, path, body=None, query=None, headers=None, expected_code=0)`,不要重复实现登录鉴权。
13. 保持代码贴近现有项目风格,不使用过度复杂的框架封装。
## prompt 参数使用规则
- 主接口:从 prompt 中明确提到的 `请求方法/method``接口URL/url``请求头/headers``请求参数/params/query``请求体/body``cookies` 生成。
- 前置接口:如果 prompt 提供 `前置``前置接口``setup` JSON必须在主请求前执行并支持从前置响应中提取变量。
- 变量提取:如果 prompt 提供 `提取``取值``extract` JSON应按指定 JSON 路径从响应中取值,并用于后续 headers、params、body 或 URL 模板。
- 后置接口:如果 prompt 提供 `后置``后置接口``teardown` JSON必须使用 `try/finally``teardown_method` 保证清理逻辑尽量执行。
- 不允许把 prompt 中没有明确给出的接口、字段、token、账号、密码、断言值自行补出来。
## 断言生成规则
- `status_code` 类型转为 `assert response.status_code == xxx`
- `json_equal` 类型转为对 `response.json()` 对应路径的等值断言。
- `json_exists` 类型转为对应 JSON 路径存在且非空断言。
- 如果 expectedResults 只描述“成功/正常”,至少断言 HTTP 状态码为 200 或 201。
- 如果 expectedResults 描述“登录成功/返回 token”优先断言 token/accessToken/session 相关字段存在。
## 推荐代码结构
```python
# -*- coding: utf-8 -*-
import allure
import logging
import requests
import json
import pytest
logging.basicConfig(level=logging.INFO, format='%(asctime)s - %(levelname)s - %(message)s')
BASE_URL = "..."
def _mask_sensitive(data):
...
@allure.feature("模块名称")
class TestXxx(object):
def setup_method(self):
logging.info("-----------------------------Test Start-------------------------------")
def teardown_method(self):
logging.info("-----------------------------Test End-------------------------------")
@allure.story("验证xxx")
@allure.title("测试xxx接口")
def test_xxx(self):
with allure.step("1. 准备请求参数"):
...
with allure.step("2. 发送接口请求"):
...
with allure.step("3. 验证响应"):
...
```

View File

@@ -0,0 +1,81 @@
# -*- coding:utf-8 -*-
import re
def _append_unique(assertions, assertion):
for existing in assertions:
if existing == assertion:
return
assertions.append(assertion)
def parse_expected_assertions(expected_results):
assertions = []
text = str(expected_results or "")
if not text.strip():
return assertions
status_match = re.search(r"(?:HTTP)?\s*状态码\s*(?:为|是|=|等于)?\s*(\d{3})", text, re.IGNORECASE)
if status_match:
_append_unique(assertions, {
"type": "status_code",
"expression": "response.status_code == {0}".format(status_match.group(1)),
"source": status_match.group(0)
})
for match in re.finditer(r"(?:返回)?\s*code\s*(?:为|是|=|等于)\s*([0-9]+)", text, re.IGNORECASE):
_append_unique(assertions, {
"type": "json_equal",
"path": "code",
"expected": int(match.group(1)),
"expression": "response_json.get('code') == {0}".format(match.group(1)),
"source": match.group(0)
})
for match in re.finditer(r"(?:返回)?\s*(?:message|msg)\s*(?:为|是|=|等于)\s*['\"]?([^'\",。;;\n\r]+)['\"]?", text, re.IGNORECASE):
expected = match.group(1).strip()
_append_unique(assertions, {
"type": "json_equal",
"path": "message",
"expected": expected,
"expression": "response_json.get('message') == {0!r}".format(expected),
"source": match.group(0)
})
field_patterns = [
r"返回\s*([A-Za-z0-9_.]+)\s*字段",
r"包含\s*([A-Za-z0-9_.]+)\s*字段",
r"([A-Za-z0-9_.]*(?:token|accessToken|data|id|list|records|total)[A-Za-z0-9_.]*)\s*(?:不为空|存在)",
]
for pattern in field_patterns:
for match in re.finditer(pattern, text, re.IGNORECASE):
path = match.group(1).strip(" .")
if not path:
continue
if path.lower() in ("http", "code", "message", "msg"):
continue
if "." not in path and path.lower() in ("token", "accesstoken"):
path = "data.{0}".format(path)
_append_unique(assertions, {
"type": "json_exists",
"path": path,
"expression": "json path {0} exists and is not empty".format(path),
"source": match.group(0)
})
if ("成功" in text or "正常" in text) and not any(item.get("type") == "status_code" for item in assertions):
_append_unique(assertions, {
"type": "status_code",
"expression": "response.status_code == 200",
"source": "成功/正常"
})
if ("登录成功" in text or "token" in text.lower()) and not any(item.get("path") in ("data.token", "token", "data.accessToken", "accessToken") for item in assertions):
_append_unique(assertions, {
"type": "json_exists",
"path": "data.token",
"expression": "json path data.token exists and is not empty",
"source": "登录成功/token"
})
return assertions

View File

@@ -0,0 +1,453 @@
# -*- coding:utf-8 -*-
import json
import os
import re
import requests
from base_framework.platform_tools.Create_api_testcase.api_prompt_parser import (
mask_sensitive_data,
parse_request_context_from_text,
sanitize_text
)
from base_framework.platform_tools.Create_api_testcase.assertion_parser import parse_expected_assertions
CURRENT_DIR = os.path.dirname(os.path.abspath(__file__))
PROJECT_ROOT = os.path.abspath(os.path.join(CURRENT_DIR, "..", "..", ".."))
LOCAL_CONFIG_PATH = os.path.join(CURRENT_DIR, "config.json")
SHARED_CONFIG_PATH = os.path.join(CURRENT_DIR, "..", "Create_ui_testcase", "config.json")
SKILL_PATH = os.path.join(CURRENT_DIR, "api_testing_skill.md")
GENERATED_CASES_DIR = os.path.join(CURRENT_DIR, "generated_cases")
DEFAULT_HEADERS = {
"Accept": "application/json, text/plain, */*",
"Accept-Language": "zh-CN,zh;q=0.9,en-US;q=0.8,en;q=0.7",
"Cache-Control": "no-cache",
"Connection": "keep-alive",
"Content-Type": "application/json;charset=UTF-8",
"Pragma": "no-cache",
"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36"
}
MODEL_HEADERS = {
"Content-Type": "application/json",
"Accept": "text/event-stream"
}
def load_config(config_path=None):
env_config = {
"api_key": os.getenv("ROUTIN_API_KEY"),
"base_url": os.getenv("ROUTIN_BASE_URL"),
"model": os.getenv("ROUTIN_MODEL")
}
if all(env_config.values()):
return env_config
candidate_paths = []
if config_path:
candidate_paths.append(config_path)
candidate_paths.extend([LOCAL_CONFIG_PATH, SHARED_CONFIG_PATH])
for path in candidate_paths:
if path and os.path.exists(path):
with open(path, "r", encoding="utf-8") as file:
config = json.load(file)
for key, value in env_config.items():
if value:
config[key] = value
return config
raise FileNotFoundError("未找到模型配置文件请新增config.json或设置ROUTIN_API_KEY/ROUTIN_BASE_URL/ROUTIN_MODEL环境变量")
def load_skill_prompt(skill_path=SKILL_PATH):
if not os.path.exists(skill_path):
raise FileNotFoundError("接口自动化Skill文件不存在{0}".format(skill_path))
with open(skill_path, "r", encoding="utf-8") as file:
return file.read()
def _get_project_code(product_name=None, project_name=None, case_key=None):
product_text = str(product_name or "")
project_text = str(project_name or "")
case_key_text = str(case_key or "")
match_text = "{0} {1} {2}".format(product_text, project_text, case_key_text).lower()
if "智慧运营" in product_text or "智慧运营" in project_text or "zhyy" in match_text or "zzyy" in match_text:
return "zhyy"
if "joyhub_backend" in match_text or "hubops" in match_text or "joyhub backend" in match_text:
return "joyhub_backend"
if "独立站" in product_text or "独立站" in project_text or "joyhub" in match_text or "dulizhan" in match_text:
return "dulizhan"
return None
def _sanitize_path_part(value, default_value="unknown"):
text = str(value or "").strip()
if not text:
text = default_value
return re.sub(r'[\\/:*?"<>|\s]+', "_", text).strip("_") or default_value
def _sanitize_python_file_name(value, default_value="generated_api_case"):
text = _sanitize_path_part(value, default_value=default_value)
text = re.sub(r"[^0-9A-Za-z_\u4e00-\u9fa5]+", "_", text).strip("_")
if not text:
text = default_value
if not text.startswith("test_"):
text = "test_{0}".format(text)
return "{0}.py".format(text)
def _resolve_api_testcase_dir(product_name, project_name, case_key, module_name):
project_code = _get_project_code(
product_name=product_name,
project_name=project_name,
case_key=case_key
)
safe_module_name = _sanitize_path_part(module_name, default_value="Generated")
if project_code == "joyhub_backend":
return os.path.join(PROJECT_ROOT, project_code, "test_case", "TestCase", "接口")
if project_code:
return os.path.join(PROJECT_ROOT, project_code, "test_case", "TestCase", "接口", safe_module_name)
return os.path.join(
GENERATED_CASES_DIR,
_sanitize_path_part(project_name, default_value="unknown_project"),
safe_module_name
)
def build_api_testcase_prompt(project_id,
case_id,
automation_type,
prompt,
case_key,
module_name,
product_name,
project_name,
steps,
expected_results,
parsed_api_context=None,
assertion_suggestions=None,
skill_prompt=None):
if skill_prompt is None:
skill_prompt = load_skill_prompt()
if parsed_api_context is None:
parsed_api_context = parse_request_context_from_text(prompt, steps, expected_results)
if assertion_suggestions is None:
assertion_suggestions = parse_expected_assertions(expected_results)
safe_parsed_api_context = mask_sensitive_data(parsed_api_context)
case_info = {
"projectId": project_id,
"caseId": case_id,
"automationType": automation_type,
"caseKey": case_key,
"moduleName": module_name,
"productName": product_name,
"projectName": project_name,
"steps": steps,
"expectedResults": expected_results,
"userPrompt": sanitize_text(prompt),
"parsedApiContext": safe_parsed_api_context,
"assertionSuggestions": assertion_suggestions
}
return """你需要严格遵循以下接口自动化测试生成规则:
{skill_prompt}
下面是测试平台传入的测试用例信息:
{case_info}
生成要求:
1. 只根据用户prompt中明确提供的接口、请求参数、前置参数、后置处理和预期结果生成 Python 接口自动化 pytest 用例。
2. 代码风格贴合当前项目已有接口用例requests + pytest + allure + logging。
3. 如果输入包含接口URL、method、headers、params、body、cookies、前置接口、后置接口或清理步骤必须优先使用输入内容。
4. 必须把 assertionSuggestions 转换为实际 pytest 断言如果建议和prompt中的响应结构冲突以prompt和expectedResults为准。
5. 如果缺少必要信息不要自动联想、不要编造真实地址、token、账号或密码使用TODO常量并通过pytest.skip提示补充。
6. 敏感字段不要明文写入Allure附件或日志。
7. 只输出Python代码不要输出Markdown解释。
""".format(
skill_prompt=skill_prompt,
case_info=json.dumps(case_info, ensure_ascii=False, indent=2)
)
def build_generate_automation_payload(project_id,
case_id,
automation_type,
prompt,
case_key,
module_name,
product_name,
project_name,
steps,
expected_results,
extra_fields=None):
payload = {
"projectId": project_id,
"caseId": case_id,
"automationType": automation_type,
"prompt": prompt,
"caseKey": case_key,
"moduleName": module_name,
"productName": product_name,
"projectName": project_name,
"steps": steps,
"expectedResults": expected_results
}
if isinstance(extra_fields, dict):
payload.update(extra_fields)
return payload
def extract_streaming_response_text(stream_text):
deltas = []
done_text = ""
for line in stream_text.splitlines():
line = line.strip()
if not line.startswith("data:"):
continue
payload_text = line.replace("data:", "", 1).strip()
if not payload_text or payload_text == "[DONE]":
continue
try:
payload = json.loads(payload_text)
except ValueError:
continue
event_type = payload.get("type")
if event_type == "response.output_text.delta":
delta = payload.get("delta")
if isinstance(delta, str):
deltas.append(delta)
elif event_type == "response.output_text.done":
text = payload.get("text")
if isinstance(text, str) and text.strip():
done_text = text
generated_text = "".join(deltas).strip()
if generated_text:
return generated_text
return done_text.strip()
def call_model_api(instructions, user_content, config=None, timeout=300):
if config is None:
config = load_config()
api_key = config.get("api_key")
base_url = config.get("base_url")
model = config.get("model")
if not api_key:
raise ValueError("api_key不能为空请先配置config.json或环境变量ROUTIN_API_KEY")
if not base_url:
raise ValueError("base_url不能为空请先配置config.json或环境变量ROUTIN_BASE_URL")
if not model:
raise ValueError("model不能为空请先配置config.json或环境变量ROUTIN_MODEL")
headers = MODEL_HEADERS.copy()
headers["Authorization"] = "Bearer {0}".format(api_key)
payload = {
"model": model,
"instructions": instructions,
"input": user_content,
"max_output_tokens": 4096,
"store": False,
"stream": True
}
api_url = base_url.rstrip("/")
if not api_url.endswith("/responses"):
api_url = api_url + "/responses"
response = requests.post(
url=api_url,
headers=headers,
json=payload,
timeout=timeout
)
if response.status_code >= 400:
raise RuntimeError(
"大模型Responses接口调用失败url={0}status_code={1}response={2}".format(
api_url,
response.status_code,
response.text[:2000]
)
)
generated_text = extract_streaming_response_text(response.text)
if generated_text:
return generated_text
try:
return response.json()
except ValueError:
raise RuntimeError("大模型流式响应未解析到正文,响应预览:{0}".format(response.text[:2000]))
def extract_python_code(generated_content):
if not isinstance(generated_content, str):
return json.dumps(generated_content, ensure_ascii=False, indent=2)
matches = re.findall(r"```(?:python|py)?\s*([\s\S]*?)```", generated_content, re.IGNORECASE)
python_blocks = []
for block in matches:
block_text = block.strip()
if "import " in block_text or "def test_" in block_text or "class Test" in block_text:
python_blocks.append(block_text)
if python_blocks:
return "\n\n".join(python_blocks).strip() + "\n"
return generated_content.strip() + "\n"
def _next_available_file_path(target_dir, file_name):
file_path = os.path.join(target_dir, file_name)
if not os.path.exists(file_path):
return file_path
base_name, ext = os.path.splitext(file_name)
index = 1
while True:
candidate = os.path.join(target_dir, "{0}_{1}{2}".format(base_name, index, ext))
if not os.path.exists(candidate):
return candidate
index += 1
def save_generated_api_testcase(generated_content,
product_name,
project_name,
module_name,
case_key):
target_dir = _resolve_api_testcase_dir(
product_name=product_name,
project_name=project_name,
case_key=case_key,
module_name=module_name
)
os.makedirs(target_dir, exist_ok=True)
file_name = _sanitize_python_file_name(case_key, default_value="generated_api_case")
file_path = _next_available_file_path(target_dir, file_name)
testcase_code = extract_python_code(generated_content)
with open(file_path, "w", encoding="utf-8") as file:
file.write(testcase_code)
return file_path
def generate_api_automation_testcase(project_id,
case_id,
automation_type,
prompt,
case_key,
module_name,
product_name,
project_name,
steps,
expected_results,
config=None,
skill_prompt=None,
timeout=120):
if automation_type not in ("api", "interface", "接口"):
raise ValueError("automation_type必须为api/interface/接口")
parsed_api_context = parse_request_context_from_text(prompt, steps, expected_results)
assertion_suggestions = parse_expected_assertions(expected_results)
final_prompt = build_api_testcase_prompt(
project_id=project_id,
case_id=case_id,
automation_type=automation_type,
prompt=prompt,
case_key=case_key,
module_name=module_name,
product_name=product_name,
project_name=project_name,
steps=steps,
expected_results=expected_results,
parsed_api_context=parsed_api_context,
assertion_suggestions=assertion_suggestions,
skill_prompt=skill_prompt
)
instructions = "你是资深接口自动化测试专家负责生成稳定、可维护、可落地的Python pytest接口自动化测试用例。"
return call_model_api(
instructions=instructions,
user_content=final_prompt,
config=config,
timeout=timeout
)
def generate_automation_case(url,
project_id,
case_id,
automation_type,
prompt,
case_key,
module_name,
product_name,
project_name,
steps,
expected_results,
access_token,
cookie=None,
timeout=60,
extra_fields=None):
if not url:
raise ValueError("url不能为空请由调用方传入生成自动化用例接口地址")
headers = DEFAULT_HEADERS.copy()
headers["accessToken"] = access_token
if cookie:
headers["Cookie"] = cookie
payload = build_generate_automation_payload(
project_id=project_id,
case_id=case_id,
automation_type=automation_type,
prompt=prompt,
case_key=case_key,
module_name=module_name,
product_name=product_name,
project_name=project_name,
steps=steps,
expected_results=expected_results,
extra_fields=extra_fields
)
response = requests.post(
url=url,
headers=headers,
data=json.dumps(payload, ensure_ascii=False).encode("utf-8"),
timeout=timeout
)
response.raise_for_status()
try:
return response.json()
except ValueError:
return response.text

View File

@@ -0,0 +1,165 @@
# -*- coding:utf-8 -*-
import argparse
import json
import traceback
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from urllib.parse import urlparse
from base_framework.platform_tools.Create_api_testcase.generate_api_automation import (
generate_api_automation_testcase,
save_generated_api_testcase
)
API_PATH = "/it/api/case/generate-automation"
SUPPORTED_AUTOMATION_TYPES = ("api", "interface", "接口")
class GenerateApiAutomationHandler(BaseHTTPRequestHandler):
def do_OPTIONS(self):
self._send_json_response(200, {"code": 0, "message": "ok", "data": None})
def do_POST(self):
request_path = urlparse(self.path).path
if request_path != API_PATH:
self._send_json_response(404, {
"code": 404,
"message": "接口不存在:{0}".format(request_path),
"data": None
})
return
try:
request_data = self._read_json_body()
self._validate_required_fields(request_data)
automation_type = request_data.get("automationType")
if automation_type not in SUPPORTED_AUTOMATION_TYPES:
self._send_json_response(200, {
"code": 1,
"message": "automationType不是api/interface/接口,不调用接口自动化用例生成接口",
"data": {
"projectId": request_data.get("projectId"),
"caseId": request_data.get("caseId"),
"automationType": automation_type,
"caseKey": request_data.get("caseKey"),
"content": self._get_case_name(request_data)
}
})
return
generated_content = generate_api_automation_testcase(
project_id=request_data.get("projectId"),
case_id=request_data.get("caseId"),
automation_type=automation_type,
prompt=request_data.get("prompt"),
case_key=request_data.get("caseKey"),
module_name=request_data.get("moduleName"),
product_name=request_data.get("productName"),
project_name=request_data.get("projectName"),
steps=request_data.get("steps"),
expected_results=request_data.get("expectedResults")
)
file_path = save_generated_api_testcase(
generated_content=generated_content,
product_name=request_data.get("productName"),
project_name=request_data.get("projectName"),
module_name=request_data.get("moduleName"),
case_key=request_data.get("caseKey")
)
self._send_json_response(200, {
"code": 0,
"message": "success",
"data": {
"projectId": request_data.get("projectId"),
"caseId": request_data.get("caseId"),
"automationType": automation_type,
"caseKey": request_data.get("caseKey"),
"content": self._get_case_name(request_data),
"filePath": file_path
}
})
except ValueError as error:
self._send_json_response(400, {
"code": 400,
"message": str(error),
"data": None
})
except Exception as error:
self._send_json_response(500, {
"code": 500,
"message": str(error),
"data": None,
"trace": traceback.format_exc()
})
def _read_json_body(self):
content_length = int(self.headers.get("Content-Length", 0))
if content_length <= 0:
raise ValueError("请求体不能为空")
body = self.rfile.read(content_length).decode("utf-8")
try:
return json.loads(body)
except ValueError:
raise ValueError("请求体必须是合法JSON")
def _validate_required_fields(self, request_data):
required_fields = [
"projectId",
"caseId",
"automationType",
"prompt",
"caseKey",
"moduleName",
"productName",
"projectName",
"steps",
"expectedResults"
]
missing_fields = []
for field in required_fields:
if field == "productName":
if field not in request_data or request_data.get(field) is None:
missing_fields.append(field)
elif request_data.get(field) in (None, ""):
missing_fields.append(field)
if missing_fields:
raise ValueError("缺少必填参数:{0}".format(", ".join(missing_fields)))
def _get_case_name(self, request_data):
return request_data.get("caseName") or request_data.get("title") or request_data.get("name") or "{0}-{1}".format(
request_data.get("caseKey"),
request_data.get("moduleName")
)
def _send_json_response(self, status_code, response_data):
response_body = json.dumps(response_data, ensure_ascii=False).encode("utf-8")
self.send_response(status_code)
self.send_header("Content-Type", "application/json;charset=UTF-8")
self.send_header("Access-Control-Allow-Origin", "*")
self.send_header("Access-Control-Allow-Methods", "POST, OPTIONS")
self.send_header("Access-Control-Allow-Headers", "Content-Type, accessToken, Authorization")
self.send_header("Content-Length", str(len(response_body)))
self.end_headers()
self.wfile.write(response_body)
def log_message(self, format, *args):
print("[{0}] {1}".format(self.log_date_time_string(), format % args))
def run_server(host="0.0.0.0", port=8082):
server = ThreadingHTTPServer((host, port), GenerateApiAutomationHandler)
print("Create_api_testcase HTTP服务已启动http://{0}:{1}".format(host, port))
print("接口地址POST {0}".format(API_PATH))
server.serve_forever()
if __name__ == "__main__":
parser = argparse.ArgumentParser(description="接口自动化用例生成HTTP服务")
parser.add_argument("--host", default="0.0.0.0", help="服务监听地址默认0.0.0.0")
parser.add_argument("--port", default=8082, type=int, help="服务监听端口默认8082")
args = parser.parse_args()
run_server(host=args.host, port=args.port)

View File

View File

@@ -0,0 +1,11 @@
# -*- coding: utf-8 -*-
from joyhub_backend.library.functional_case_converter import (
ApiContextItem,
ApiMatcher,
FunctionalCaseInput,
FunctionalCaseParser,
FunctionalCaseToApiAutomationService,
FunctionalStep,
LLMApiCaseConverter,
convert_functional_case,
)

View File

@@ -0,0 +1,148 @@
# -*- coding: utf-8 -*-
import json
import logging
import os
import allure
import requests
CAPTCHA_URL = os.getenv(
"JOYHUB_CAPTCHA_URL",
"http://test-manager-api.best-envision.com/admin/login/captcha",
)
LOGIN_BASE_URL = os.getenv(
"JOYHUB_LOGIN_BASE_URL",
"http://test-manager-api.best-envision.com",
)
LOGIN_PATH = os.getenv("JOYHUB_LOGIN_PATH", "/admin/login/login")
USERNAME = os.getenv("JOYHUB_USERNAME", "guojiabao")
PASSWORD = os.getenv("JOYHUB_PASSWORD", "gjb123456")
CAPTCHA = os.getenv("JOYHUB_CAPTCHA", "1111")
TIMEOUT = int(os.getenv("JOYHUB_TIMEOUT", "20"))
TENANT_ID = os.getenv("JOYHUB_TENANT_ID", "126")
class JoyhubAuth(object):
def __init__(self):
self.session = requests.Session()
self._token = None
@property
def login_url(self):
return LOGIN_BASE_URL.rstrip("/") + LOGIN_PATH
def get_captcha_key(self):
with allure.step("前置:获取登录验证码 key"):
logging.info("GET %s", CAPTCHA_URL)
response = self.session.get(CAPTCHA_URL, timeout=TIMEOUT)
self._attach_response("captcha", response)
response.raise_for_status()
data = response.json()
key = self._extract_key(data)
assert key, "验证码接口未返回 key响应{}".format(data)
return key
def login(self):
if self._token:
return self._token
key = self.get_captcha_key()
body = {
"key": key,
"username": USERNAME,
"password": PASSWORD,
"captcha": CAPTCHA,
}
headers = {"Content-Type": "application/json", "tenant-id": TENANT_ID}
with allure.step("前置:登录并获取 token"):
logging.info("POST %s", self.login_url)
logging.info("request headers: %s", headers)
logging.info("request body: %s", self._safe_body(body))
response = self.session.post(self.login_url, json=body, headers=headers, timeout=TIMEOUT)
self._attach_request(self.login_url, "POST", headers, self._safe_body(body))
self._attach_response("login", response)
response.raise_for_status()
data = response.json()
token = self._extract_token(data)
assert token, "登录接口未返回 token响应{}".format(data)
self._token = token if token.startswith("Bearer ") else "Bearer " + token
return self._token
def auth_headers(self):
return {
"Authorization": self.login(),
"Content-Type": "application/json",
"tenant-id": TENANT_ID,
}
@staticmethod
def _extract_key(data):
if isinstance(data, dict):
for field in ("key", "captchaKey", "captcha_key", "uuid"):
if data.get(field):
return data.get(field)
nested = data.get("data")
if isinstance(nested, dict):
for field in ("key", "captchaKey", "captcha_key", "uuid"):
if nested.get(field):
return nested.get(field)
if isinstance(nested, str):
return nested
return None
@staticmethod
def _extract_token(data):
token_fields = (
"token",
"access_token",
"accessToken",
"Authorization",
"authorization",
"userToken",
"user_token",
"jwt",
)
if isinstance(data, dict):
for field in token_fields:
token = data.get(field)
if JoyhubAuth._looks_like_token(token):
return token
nested = data.get("data")
if isinstance(nested, dict):
for field in token_fields:
token = nested.get(field)
if JoyhubAuth._looks_like_token(token):
return token
return None
@staticmethod
def _looks_like_token(value):
if not isinstance(value, str):
return False
value = value.strip()
if not value:
return False
if any(ord(char) > 127 for char in value):
return False
return len(value) >= 16 or value.startswith("Bearer ")
@staticmethod
def _safe_body(body):
safe = dict(body)
if "password" in safe:
safe["password"] = "******"
return safe
@staticmethod
def _attach_request(url, method, headers, body):
allure.attach(str(url), "请求url", allure.attachment_type.TEXT)
allure.attach(str(method), "请求方式", allure.attachment_type.TEXT)
allure.attach(json.dumps(headers, ensure_ascii=False, indent=2), "请求头", allure.attachment_type.JSON)
allure.attach(json.dumps(body, ensure_ascii=False, indent=2), "请求体", allure.attachment_type.JSON)
@staticmethod
def _attach_response(name, response):
logging.info("%s response status: %s", name, response.status_code)
logging.info("%s response body: %s", name, response.text)
allure.attach(str(response.status_code), "{} 响应状态码".format(name), allure.attachment_type.TEXT)
allure.attach(response.text, "{} 响应体".format(name), allure.attachment_type.JSON)

View File

@@ -0,0 +1,62 @@
# -*- coding: utf-8 -*-
import logging
from joyhub_backend.library.joyhub_interface import JoyhubInterface
class JoyhubBusiness(JoyhubInterface):
def get_video_label_list(self):
logging.info("==========获取视频标签列表==========")
body = {
"page": 1,
"limit": 10,
"sort": "id",
"label_name": "",
"category_id": 0,
"video_type": 0,
"video_num": 0,
"created_at": [],
"order": "descending",
}
return self.request("获取视频标签列表", "POST", "admin/video/getVideoLabelList", body=body)
def get_video_category_list(self):
logging.info("==========获取视频分类列表==========")
body = {
"page": 1,
"limit": 10,
"sort": "id",
"category_name": "",
"order": "descending",
}
return self.request("获取视频分类列表", "POST", "admin/video/getVideoCategoryList", body=body)
def get_exchange_record_list(self):
logging.info("==========兑换记录列表==========")
body = {
"page": 1,
"limit": 10,
"status": "",
"goods_type": "",
"created_at": [],
}
return self.request("兑换记录列表", "POST", "admin/exchange/recordList", body=body)
def get_app_feedback_list(self):
logging.info("==========app反馈意见列表==========")
body = {
"page": 1,
"limit": 10,
"status": "",
"keyword": "",
}
return self.request("app反馈意见列表", "POST", "admin/feedback/list", body=body)
def get_sensitive_word_list(self):
logging.info("==========敏感词列表==========")
body = {
"page": 1,
"limit": 10,
"keyword": "",
}
return self.request("敏感词列表", "POST", "admin/sensitiveWord/list", body=body)

View File

@@ -0,0 +1,917 @@
# -*- coding: utf-8 -*-
import argparse
import copy
import json
import os
import re
from dataclasses import asdict, dataclass, field
from difflib import SequenceMatcher
from typing import Any, Callable, Dict, Iterable, List, Optional, Union
COMMON_CN_KEYWORDS = [
"登录",
"查询",
"搜索",
"筛选",
"列表",
"详情",
"新增",
"创建",
"添加",
"修改",
"编辑",
"更新",
"删除",
"导出",
"导入",
"上传",
"下载",
"提交",
"审核",
"确认",
"保存",
"分页",
"分页查询",
"性能",
"响应时间",
"超时",
"返回",
"结果",
"成功",
"失败",
"校验",
"重置",
"启用",
"停用",
"开关",
"状态",
"排序",
"标签",
"分类",
"关键字",
"关键字搜索",
"模糊搜索",
]
METHOD_INTENT_KEYWORDS = {
"GET": ["查询", "搜索", "筛选", "列表", "详情", "获取", "查看", "分页", "返回"],
"POST": ["新增", "创建", "添加", "登录", "提交", "导入", "上传", "确认", "审核", "保存"],
"PUT": ["修改", "编辑", "更新", "重置"],
"PATCH": ["修改", "编辑", "更新", "状态", "启用", "停用", "开关"],
"DELETE": ["删除", "移除"],
}
DEFAULT_PLACEHOLDER_URL = "/api/need-confirm"
DEFAULT_PAGE_SIZE = 10
DEFAULT_RESPONSE_TIME_MS = 1000
@dataclass
class FunctionalStep:
stepNo: int
action: str
expectedResult: str = ""
@dataclass
class ApiContextItem:
apiName: str = ""
method: str = ""
url: str = ""
description: str = ""
headers: Dict[str, Any] = field(default_factory=dict)
queryParams: Dict[str, Any] = field(default_factory=dict)
requestBody: Any = None
responseExample: Any = None
tags: List[str] = field(default_factory=list)
@dataclass
class FunctionalCaseInput:
caseId: str = ""
caseKey: str = ""
caseName: str = ""
projectName: str = ""
moduleName: str = ""
priority: str = ""
preconditions: str = ""
steps: List[FunctionalStep] = field(default_factory=list)
expectedResults: List[str] = field(default_factory=list)
apiContext: Optional[List[ApiContextItem]] = None
generateOptions: Dict[str, Any] = field(default_factory=dict)
extra: Dict[str, Any] = field(default_factory=dict)
class FunctionalCaseParser:
@classmethod
def parse(cls, payload: Union[str, Dict[str, Any], FunctionalCaseInput]) -> FunctionalCaseInput:
if isinstance(payload, FunctionalCaseInput):
return payload
if isinstance(payload, str):
try:
payload = json.loads(payload)
except Exception:
payload = {
"caseName": "手工输入功能用例",
"steps": [payload],
"expectedResults": [],
}
if not isinstance(payload, dict):
raise TypeError("functional case payload must be dict, json string or FunctionalCaseInput")
steps = cls._normalize_steps(payload.get("steps") or payload.get("step") or [])
expected_results = cls._normalize_text_list(
payload.get("expectedResults")
or payload.get("expectedResult")
or payload.get("expectations")
or []
)
api_context = cls._normalize_api_context(payload.get("apiContext") or payload.get("apis") or payload.get("apiInfo"))
generate_options = payload.get("generateOptions") or payload.get("options") or {}
known_keys = {
"caseId",
"caseKey",
"caseName",
"projectName",
"moduleName",
"priority",
"preconditions",
"steps",
"step",
"expectedResults",
"expectedResult",
"expectations",
"apiContext",
"apis",
"apiInfo",
"generateOptions",
"options",
}
extra = {key: value for key, value in payload.items() if key not in known_keys}
return FunctionalCaseInput(
caseId=str(payload.get("caseId", "") or ""),
caseKey=str(payload.get("caseKey", "") or ""),
caseName=str(payload.get("caseName", "") or payload.get("name", "") or ""),
projectName=str(payload.get("projectName", "") or ""),
moduleName=str(payload.get("moduleName", "") or ""),
priority=str(payload.get("priority", "") or ""),
preconditions=str(payload.get("preconditions", "") or payload.get("precondition", "") or ""),
steps=steps,
expectedResults=expected_results,
apiContext=api_context,
generateOptions=generate_options if isinstance(generate_options, dict) else {},
extra=extra,
)
@staticmethod
def _normalize_steps(value: Any) -> List[FunctionalStep]:
if not value:
return []
if isinstance(value, str):
raw_items = [item.strip() for item in re.split(r"[\n\r]+", value) if item.strip()]
if len(raw_items) <= 1:
raw_items = [item.strip() for item in re.split(r"(?<=[。;;])", value) if item.strip()]
items = raw_items
elif isinstance(value, list):
items = value
else:
items = [value]
normalized: List[FunctionalStep] = []
for index, item in enumerate(items, start=1):
if isinstance(item, dict):
step_no = int(item.get("stepNo") or item.get("step_no") or item.get("no") or index)
action = str(item.get("action") or item.get("step") or item.get("content") or item.get("description") or "")
expected_result = str(item.get("expectedResult") or item.get("expected") or "")
else:
raw_text = str(item).strip()
step_no = index
action = re.sub(r"^\s*\d+[\.、\)]\s*", "", raw_text)
expected_result = ""
if action:
normalized.append(FunctionalStep(stepNo=step_no, action=action, expectedResult=expected_result))
return normalized
@staticmethod
def _normalize_text_list(value: Any) -> List[str]:
if not value:
return []
if isinstance(value, str):
parts = [item.strip() for item in re.split(r"[\n\r]+", value) if item.strip()]
if len(parts) <= 1:
parts = [item.strip() for item in re.split(r"[。;;]\s*", value) if item.strip()]
return [item for item in parts if item]
if isinstance(value, list):
result = []
for item in value:
if isinstance(item, dict):
text = str(item.get("text") or item.get("content") or item.get("expected") or "").strip()
else:
text = str(item).strip()
if text:
result.append(text)
return result
return [str(value).strip()] if str(value).strip() else []
@staticmethod
def _normalize_api_context(value: Any) -> Optional[List[ApiContextItem]]:
if not value:
return None
if isinstance(value, dict):
if "apis" in value and isinstance(value["apis"], list):
value = value["apis"]
elif "items" in value and isinstance(value["items"], list):
value = value["items"]
else:
value = [value]
if isinstance(value, str):
value = value.strip()
if not value:
return None
try:
parsed = json.loads(value)
return FunctionalCaseParser._normalize_api_context(parsed)
except Exception:
return FunctionalCaseParser._parse_markdown_api_context(value)
if not isinstance(value, list):
return None
apis: List[ApiContextItem] = []
for item in value:
if isinstance(item, ApiContextItem):
apis.append(item)
continue
if not isinstance(item, dict):
continue
headers = item.get("headers") or item.get("header") or {}
query_params = item.get("queryParams") or item.get("query") or item.get("params") or {}
tags = item.get("tags") or []
if isinstance(tags, str):
tags = [tag.strip() for tag in re.split(r"[\s,/|]+", tags) if tag.strip()]
apis.append(
ApiContextItem(
apiName=str(item.get("apiName") or item.get("name") or ""),
method=str(item.get("method") or item.get("httpMethod") or "").upper(),
url=str(item.get("url") or item.get("path") or ""),
description=str(item.get("description") or item.get("desc") or ""),
headers=headers if isinstance(headers, dict) else {},
queryParams=query_params if isinstance(query_params, dict) else {},
requestBody=item.get("requestBody") if "requestBody" in item else item.get("body"),
responseExample=item.get("responseExample") or item.get("response") or item.get("example"),
tags=tags if isinstance(tags, list) else [],
)
)
return apis or None
@staticmethod
def _parse_markdown_api_context(text: str) -> Optional[List[ApiContextItem]]:
lines = text.splitlines()
apis: List[ApiContextItem] = []
current: Dict[str, Any] = {}
in_body = False
body_lines: List[str] = []
def flush_current():
nonlocal current, body_lines, in_body
if not current:
return
body_text = "\n".join(body_lines).strip()
request_body = FunctionalCaseParser._safe_json_load(body_text)
apis.append(
ApiContextItem(
apiName=current.get("apiName", ""),
method=current.get("method", ""),
url=current.get("url", ""),
description=current.get("description", ""),
headers=current.get("headers", {}),
queryParams=current.get("queryParams", {}),
requestBody=request_body if request_body is not None else body_text,
responseExample=current.get("responseExample"),
tags=current.get("tags", []),
)
)
current = {}
body_lines = []
in_body = False
for line in lines:
stripped = line.strip()
if stripped.startswith("## "):
flush_current()
current["apiName"] = stripped[3:].strip()
continue
if stripped == "**接口URL**":
continue
if stripped.startswith("> ") and not current.get("url"):
value = stripped[2:].strip()
if value and value != "暂无参数":
current["url"] = value
continue
if stripped == "**请求方式**":
continue
if stripped == "**请求Body参数**":
in_body = True
body_lines = []
continue
if stripped == "**响应示例**":
in_body = False
continue
if in_body:
if stripped.startswith("```"):
continue
body_lines.append(line)
flush_current()
return apis or None
@staticmethod
def _safe_json_load(text: str) -> Any:
if not text:
return None
cleaned = text.strip()
if cleaned.startswith("```"):
cleaned = re.sub(r"^```[a-zA-Z0-9_-]*\s*", "", cleaned)
cleaned = re.sub(r"\s*```$", "", cleaned)
try:
return json.loads(cleaned)
except Exception:
return None
class ApiMatcher:
def match(self, functional_case: FunctionalCaseInput, candidates: List[ApiContextItem], top_k: int = 3) -> List[Dict[str, Any]]:
matched: List[Dict[str, Any]] = []
keywords = self.extract_keywords(functional_case)
intent = self.detect_intent(functional_case)
for api in candidates:
score = self._score_api(api, keywords, intent, functional_case)
if score <= 0:
continue
matched.append(
{
"score": round(score, 4),
"api": api,
"reason": self._build_reason(api, keywords, intent),
}
)
matched.sort(key=lambda item: item["score"], reverse=True)
return matched[:top_k]
def extract_keywords(self, functional_case: FunctionalCaseInput) -> List[str]:
texts = [functional_case.caseName, functional_case.moduleName, functional_case.preconditions]
texts.extend(step.action for step in functional_case.steps)
texts.extend(step.expectedResult for step in functional_case.steps if step.expectedResult)
texts.extend(functional_case.expectedResults)
texts.extend(str(value) for value in functional_case.extra.values() if value is not None)
full_text = " \n ".join(texts)
keywords: List[str] = []
for keyword in COMMON_CN_KEYWORDS:
if keyword in full_text and keyword not in keywords:
keywords.append(keyword)
english_tokens = re.findall(r"[A-Za-z_][A-Za-z0-9_]{1,}", full_text)
for token in english_tokens:
if token not in keywords:
keywords.append(token)
generic_tokens = re.findall(r"[\u4e00-\u9fa5]{2,}", full_text)
for token in generic_tokens:
if len(token) <= 2:
if token not in keywords:
keywords.append(token)
continue
if token not in keywords:
keywords.append(token)
return self._dedupe(keywords)
def detect_intent(self, functional_case: FunctionalCaseInput) -> str:
text = self._all_text(functional_case)
for method, keywords in METHOD_INTENT_KEYWORDS.items():
if any(keyword in text for keyword in keywords):
return method
return "GET"
def _score_api(self, api: ApiContextItem, keywords: List[str], intent: str, functional_case: FunctionalCaseInput) -> float:
score = 0.0
api_text = " ".join(
[
api.apiName or "",
api.method or "",
api.url or "",
api.description or "",
" ".join(api.tags or []),
" ".join(api.headers.keys()),
" ".join(api.queryParams.keys()),
]
)
if api.method and api.method.upper() == intent:
score += 1.2
elif api.method and api.method.upper() in ("GET", "POST", "PUT", "PATCH", "DELETE"):
score += 0.2
for keyword in keywords:
if keyword and keyword in api_text:
score += 0.8
for token in self._url_tokens(api.url):
if token and any(keyword in token or token in keyword for keyword in keywords):
score += 0.6
if functional_case.moduleName and functional_case.moduleName in api_text:
score += 0.8
if functional_case.caseName and functional_case.caseName in api_text:
score += 0.5
ratio = SequenceMatcher(None, self._all_text(functional_case), api_text).ratio()
score += ratio * 0.8
return score
@staticmethod
def _build_reason(api: ApiContextItem, keywords: List[str], intent: str) -> str:
matched_keywords = [keyword for keyword in keywords if keyword and keyword in (api.apiName + " " + api.description + " " + api.url)]
parts = []
if api.method and api.method.upper() == intent:
parts.append("方法匹配")
if matched_keywords:
parts.append("关键词命中:{}".format("".join(matched_keywords[:5])))
return "; ".join(parts) or "模糊匹配"
@staticmethod
def _url_tokens(url: str) -> List[str]:
if not url:
return []
return [token for token in re.split(r"[/?&=_\-\.]+", url) if token]
@staticmethod
def _dedupe(items: Iterable[str]) -> List[str]:
seen = set()
result = []
for item in items:
if item and item not in seen:
seen.add(item)
result.append(item)
return result
@staticmethod
def _all_text(functional_case: FunctionalCaseInput) -> str:
texts = [functional_case.caseName, functional_case.moduleName, functional_case.preconditions]
texts.extend(step.action for step in functional_case.steps)
texts.extend(step.expectedResult for step in functional_case.steps if step.expectedResult)
texts.extend(functional_case.expectedResults)
return " \n ".join(filter(None, texts))
class LLMApiCaseConverter:
def convert(self, prompt: str, llm_client: Any = None) -> Optional[Dict[str, Any]]:
if llm_client is None:
return None
raw = self._invoke_client(llm_client, prompt)
if raw is None:
return None
if isinstance(raw, dict):
return raw
if hasattr(raw, "content"):
raw = raw.content
elif hasattr(raw, "text"):
raw = raw.text
elif hasattr(raw, "data"):
raw = raw.data
if isinstance(raw, dict):
return raw
if not isinstance(raw, str):
raw = str(raw)
return self._extract_json(raw)
@staticmethod
def _invoke_client(llm_client: Any, prompt: str) -> Any:
if callable(llm_client):
try:
return llm_client(prompt=prompt)
except TypeError:
try:
return llm_client(prompt)
except TypeError:
return llm_client({"prompt": prompt})
if hasattr(llm_client, "generate") and callable(llm_client.generate):
return llm_client.generate(prompt)
if hasattr(llm_client, "chat") and callable(llm_client.chat):
return llm_client.chat(prompt)
raise TypeError("llm_client must be callable or expose generate/chat")
@staticmethod
def _extract_json(text: str) -> Optional[Dict[str, Any]]:
if not text:
return None
cleaned = text.strip()
match = re.search(r"```json\s*(\{.*?\})\s*```", cleaned, flags=re.S)
if match:
cleaned = match.group(1)
else:
first = cleaned.find("{")
last = cleaned.rfind("}")
if first >= 0 and last > first:
cleaned = cleaned[first : last + 1]
try:
parsed = json.loads(cleaned)
return parsed if isinstance(parsed, dict) else {"data": parsed}
except Exception:
return None
class FunctionalCaseToApiAutomationService:
def __init__(self, matcher: Optional[ApiMatcher] = None):
self.matcher = matcher or ApiMatcher()
self.llm_converter = LLMApiCaseConverter()
def convert(self, payload: Union[str, Dict[str, Any], FunctionalCaseInput], llm_client: Any = None) -> Dict[str, Any]:
functional_case = FunctionalCaseParser.parse(payload)
options = self._build_options(functional_case)
candidates = functional_case.apiContext or []
matched = self.matcher.match(functional_case, candidates, top_k=int(options.get("topK", 3))) if candidates else []
llm_result = None
if options.get("useLLM", True) and llm_client is not None:
prompt = self.build_prompt(functional_case, matched, options)
llm_result = self.llm_converter.convert(prompt, llm_client)
if llm_result:
return self._finalize_llm_result(functional_case, matched, llm_result, options)
return self._build_rule_result(functional_case, matched, options)
def build_prompt(self, functional_case: FunctionalCaseInput, matched: List[Dict[str, Any]], options: Dict[str, Any]) -> str:
payload = {
"functionalCase": asdict(functional_case),
"matchedApis": [
{
"score": item["score"],
"reason": item["reason"],
"api": asdict(item["api"]),
}
for item in matched
],
"options": options,
}
return (
"你是接口自动化测试用例生成专家。\n"
"请把功能测试用例转换成接口自动化测试用例。\n"
"要求:\n"
"1. 只输出严格 JSON。\n"
"2. 如果给了候选接口信息,优先使用候选接口。\n"
"3. 无法确认的接口信息写入 missingInfo。\n"
"4. 需要包含 method、url、headers、queryParams、body、assertions、performanceAssertions。\n"
"5. 如果涉及性能要求,生成 responseTime 断言。\n"
"6. 如果涉及登录态,使用 ${token}\n\n"
"输入数据:\n"
f"{json.dumps(payload, ensure_ascii=False, indent=2)}\n\n"
"输出 JSON 结构:\n"
"{\n"
' "caseId": "",\n'
' "caseKey": "",\n'
' "caseName": "",\n'
' "automationType": "api",\n'
' "convertStatus": "SUCCESS",\n'
' "apiTestCases": [],\n'
' "missingInfo": [],\n'
' "warnings": []\n'
"}"
)
def _build_options(self, functional_case: FunctionalCaseInput) -> Dict[str, Any]:
options = copy.deepcopy(functional_case.generateOptions or {})
options.setdefault("useLLM", True)
options.setdefault("outputFormat", "json")
options.setdefault("targetFramework", "pytest")
options.setdefault("allowMissingInfo", True)
options.setdefault("topK", 3)
return options
def _build_rule_result(self, functional_case: FunctionalCaseInput, matched: List[Dict[str, Any]], options: Dict[str, Any]) -> Dict[str, Any]:
api_test_cases: List[Dict[str, Any]] = []
missing_info: List[str] = []
warnings: List[str] = []
if matched:
for index, item in enumerate(matched, start=1):
api_test_cases.append(self._build_api_test_case(functional_case, item["api"], index, item["score"], options))
else:
api_test_cases.append(self._build_fallback_test_case(functional_case, options))
missing_info.extend(self._collect_missing_info(functional_case))
warnings.append("未匹配到真实接口信息,已生成占位自动化用例")
missing_info = self._dedupe_text(missing_info)
warnings = self._dedupe_text(warnings)
convert_status = "SUCCESS"
if missing_info:
convert_status = "SUCCESS_WITH_MISSING_INFO"
if warnings and not matched:
convert_status = "DRAFT"
return {
"caseId": functional_case.caseId,
"caseKey": functional_case.caseKey,
"caseName": functional_case.caseName,
"projectName": functional_case.projectName,
"moduleName": functional_case.moduleName,
"automationType": "api",
"convertStatus": convert_status,
"apiTestCases": api_test_cases,
"missingInfo": missing_info,
"warnings": warnings,
}
def _finalize_llm_result(self, functional_case: FunctionalCaseInput, matched: List[Dict[str, Any]], llm_result: Dict[str, Any], options: Dict[str, Any]) -> Dict[str, Any]:
result = copy.deepcopy(llm_result)
result.setdefault("caseId", functional_case.caseId)
result.setdefault("caseKey", functional_case.caseKey)
result.setdefault("caseName", functional_case.caseName)
result.setdefault("projectName", functional_case.projectName)
result.setdefault("moduleName", functional_case.moduleName)
result.setdefault("automationType", "api")
result.setdefault("convertStatus", "SUCCESS")
result.setdefault("apiTestCases", [])
result.setdefault("missingInfo", [])
result.setdefault("warnings", [])
if not result.get("apiTestCases"):
rule_result = self._build_rule_result(functional_case, matched, options)
result["apiTestCases"] = rule_result["apiTestCases"]
if not result.get("missingInfo"):
result["missingInfo"] = rule_result["missingInfo"]
if not result.get("warnings"):
result["warnings"] = rule_result["warnings"]
result["convertStatus"] = rule_result["convertStatus"]
result["missingInfo"] = self._dedupe_text(result.get("missingInfo", []))
result["warnings"] = self._dedupe_text(result.get("warnings", []))
return result
def _build_api_test_case(self, functional_case: FunctionalCaseInput, api: ApiContextItem, index: int, score: float, options: Dict[str, Any]) -> Dict[str, Any]:
method = (api.method or self.matcher.detect_intent(functional_case)).upper()
query_params = copy.deepcopy(api.queryParams or {})
body = copy.deepcopy(api.requestBody)
headers = copy.deepcopy(api.headers or {})
if not headers:
headers = {"Content-Type": "application/json"}
if not any(key.lower() == "authorization" for key in headers):
headers["Authorization"] = "Bearer ${token}"
variables = self._build_variables(functional_case, api, query_params, body)
query_params = self._fill_placeholders(query_params, variables)
body = self._fill_placeholders(body, variables)
assertions = self._build_assertions(functional_case, api, method)
performance_assertions = self._build_performance_assertions(functional_case)
step_name = api.apiName or functional_case.caseName or "接口自动化步骤"
return {
"stepNo": index,
"name": step_name,
"apiName": api.apiName or step_name,
"method": method,
"url": api.url or self._infer_placeholder_url(functional_case),
"headers": headers,
"queryParams": query_params,
"body": body,
"extractVariables": [],
"assertions": assertions,
"performanceAssertions": performance_assertions,
"variables": variables,
"matchedApi": {
"apiName": api.apiName,
"method": method,
"url": api.url,
"matchScore": round(score, 4),
},
}
def _build_fallback_test_case(self, functional_case: FunctionalCaseInput, options: Dict[str, Any]) -> Dict[str, Any]:
method = self.matcher.detect_intent(functional_case)
url = self._infer_placeholder_url(functional_case)
variables = self._build_variables(functional_case, None, {}, None)
return {
"stepNo": 1,
"name": functional_case.caseName or "功能用例转接口自动化",
"apiName": functional_case.caseName or "待确认接口",
"method": method,
"url": url,
"headers": {
"Content-Type": "application/json",
"Authorization": "Bearer ${token}",
},
"queryParams": {},
"body": None,
"extractVariables": [],
"assertions": self._build_assertions(functional_case, None, method),
"performanceAssertions": self._build_performance_assertions(functional_case),
"variables": variables,
"matchedApi": {
"apiName": "",
"method": method,
"url": url,
"matchScore": 0,
},
}
def _build_variables(self, functional_case: FunctionalCaseInput, api: Optional[ApiContextItem], query_params: Dict[str, Any], body: Any) -> Dict[str, Any]:
variables: Dict[str, Any] = {}
text = self.matcher._all_text(functional_case)
for keyword in self.matcher.extract_keywords(functional_case):
if keyword in ("搜索", "查询", "列表", "筛选", "关键字", "模糊搜索"):
variables.setdefault("keyword", "测试")
if keyword in ("分页", "分页查询"):
variables.setdefault("page", 1)
variables.setdefault("limit", DEFAULT_PAGE_SIZE)
if keyword in ("登录",):
variables.setdefault("token", "${token}")
if not variables and text:
variables["keyword"] = "测试"
if query_params:
for key in query_params.keys():
if key not in variables and any(token in key.lower() for token in ["keyword", "name", "id", "page", "limit", "status", "type"]):
variables[key] = query_params[key]
if isinstance(body, dict):
for key in body.keys():
if key not in variables and any(token in key.lower() for token in ["keyword", "name", "id", "page", "limit", "status", "type"]):
variables[key] = body[key]
return variables
def _build_assertions(self, functional_case: FunctionalCaseInput, api: Optional[ApiContextItem], method: str) -> List[Dict[str, Any]]:
assertions = [
{
"type": "statusCode",
"actual": "$.code" if api or method != "DELETE" else "$.status",
"operator": "==",
"expected": 200,
}
]
text = self.matcher._all_text(functional_case)
if api and isinstance(api.responseExample, dict):
example = api.responseExample
if "code" in example:
assertions.append(
{
"type": "jsonPath",
"actual": "$.code",
"operator": "==",
"expected": example.get("code", 0),
}
)
if "msg" in example:
assertions.append(
{
"type": "jsonPath",
"actual": "$.msg",
"operator": "notEmpty",
"expected": True,
}
)
if any(keyword in text for keyword in ["搜索", "查询", "列表", "筛选", "返回"]):
assertions.append(
{
"type": "jsonPath",
"actual": "$.data",
"operator": "notEmpty",
"expected": True,
}
)
return assertions
def _build_performance_assertions(self, functional_case: FunctionalCaseInput) -> List[Dict[str, Any]]:
text = self.matcher._all_text(functional_case)
expected_ms = self._extract_response_time_ms(text)
if expected_ms is None:
return []
return [
{
"type": "responseTime",
"operator": "<=",
"expected": expected_ms,
"unit": "ms",
}
]
def _collect_missing_info(self, functional_case: FunctionalCaseInput) -> List[str]:
missing = []
if not functional_case.steps:
missing.append("缺少步骤信息")
if not functional_case.caseName:
missing.append("缺少用例名称")
if not functional_case.apiContext:
missing.append("未传入真实接口信息,已使用占位接口生成")
if self._extract_response_time_ms(self.matcher._all_text(functional_case)) is None and any(k in self.matcher._all_text(functional_case) for k in ["性能", "响应时间", "超时"]):
missing.append("性能阈值未明确,默认使用 1000ms")
return missing
@staticmethod
def _extract_response_time_ms(text: str) -> Optional[int]:
if not text:
return None
patterns = [
r"(\d+)\s*毫秒",
r"(\d+)\s*ms",
r"(\d+)\s*秒",
r"不超过\s*(\d+)\s*秒",
r"<=\s*(\d+)\s*秒",
r"小于等于\s*(\d+)\s*秒",
]
for pattern in patterns:
match = re.search(pattern, text, flags=re.I)
if match:
value = int(match.group(1))
if "" in pattern:
return value * 1000
return value
if any(keyword in text for keyword in ["响应时间", "超时", "性能"]):
return DEFAULT_RESPONSE_TIME_MS
return None
@staticmethod
def _infer_placeholder_url(functional_case: FunctionalCaseInput) -> str:
text = " ".join([functional_case.caseName, functional_case.moduleName] + [step.action for step in functional_case.steps])
if any(keyword in text for keyword in ["登录"]):
return "/api/login"
if any(keyword in text for keyword in ["搜索", "查询", "列表", "筛选"]):
return "/api/list/query"
if any(keyword in text for keyword in ["新增", "创建", "添加"]):
return "/api/create"
if any(keyword in text for keyword in ["修改", "编辑", "更新"]):
return "/api/update"
if any(keyword in text for keyword in ["删除", "移除"]):
return "/api/delete"
return DEFAULT_PLACEHOLDER_URL
@staticmethod
def _fill_placeholders(value: Any, variables: Dict[str, Any]) -> Any:
if isinstance(value, dict):
return {key: FunctionalCaseToApiAutomationService._fill_placeholders(item, variables) for key, item in value.items()}
if isinstance(value, list):
return [FunctionalCaseToApiAutomationService._fill_placeholders(item, variables) for item in value]
if isinstance(value, str):
result = value
for key, variable in variables.items():
result = result.replace("${" + key + "}", str(variable))
return result
return value
@staticmethod
def _dedupe_text(items: Iterable[str]) -> List[str]:
seen = set()
result = []
for item in items:
text = str(item).strip()
if not text or text in seen:
continue
seen.add(text)
result.append(text)
return result
def convert_functional_case(payload: Union[str, Dict[str, Any], FunctionalCaseInput], llm_client: Any = None) -> Dict[str, Any]:
return FunctionalCaseToApiAutomationService().convert(payload, llm_client=llm_client)
def load_payload_from_file(file_path: str) -> Dict[str, Any]:
with open(file_path, "r", encoding="utf-8") as file:
return json.load(file)
def main():
parser = argparse.ArgumentParser(description="功能测试用例转接口自动化用例")
parser.add_argument("--input", type=str, help="输入JSON文件路径未指定则从stdin读取")
parser.add_argument("--output", type=str, help="输出JSON文件路径未指定则打印到stdout")
args = parser.parse_args()
if args.input:
payload = load_payload_from_file(args.input)
else:
payload = json.load(os.sys.stdin)
result = convert_functional_case(payload)
output = json.dumps(result, ensure_ascii=False, indent=2)
if args.output:
with open(args.output, "w", encoding="utf-8") as file:
file.write(output)
else:
print(output)
if __name__ == "__main__":
main()

View File

@@ -0,0 +1,189 @@
# -*- coding: utf-8 -*-
import ast
import json
import os
import re
PROJECT_ROOT = os.path.abspath(os.path.join(os.path.dirname(__file__), "..", ".."))
DEFAULT_HUBOPS_PATH = os.path.join(PROJECT_ROOT, "HubOps.md")
class HubOpsParser(object):
def __init__(self, file_path=None):
self.file_path = file_path or os.getenv("JOYHUB_DOC_PATH", DEFAULT_HUBOPS_PATH)
def parse(self):
with open(self.file_path, "r", encoding="utf-8") as file:
lines = file.read().splitlines()
cases = []
headings = []
for index, line in enumerate(lines):
heading = self._parse_heading(line)
if heading:
level, title = heading
headings = [item for item in headings if item[0] < level]
headings.append((level, title))
continue
if line.strip() != "**接口URL**":
continue
case = self._parse_case(lines, index, headings)
if case.get("url"):
case["case_id"] = "joyhub_{:04d}".format(len(cases) + 1)
cases.append(case)
return cases
def _parse_case(self, lines, url_index, headings):
title = self._case_title(headings, url_index)
url = self._next_quote_value(lines, url_index)
method = self._find_section_quote(lines, url_index, "**请求方式**") or "POST"
content_type = self._find_section_quote(lines, url_index, "**Content-Type**") or "json"
body_text = self._find_code_block(lines, url_index, "**请求Body参数**")
query = self._find_param_table(lines, url_index, "**请求Query参数**")
headers = self._find_headers(lines, url_index)
body = self._parse_body(body_text)
return {
"name": title,
"method": method.upper(),
"url": url,
"content_type": content_type,
"headers": headers,
"query": query,
"body": body,
"raw_body": body_text,
}
@staticmethod
def _parse_heading(line):
match = re.match(r"^(#{2,6})\s+(.+?)\s*$", line)
if not match:
return None
return len(match.group(1)), match.group(2).strip()
@staticmethod
def _case_title(headings, url_index):
if headings:
return " / ".join(title for _, title in headings[-3:])
return "HubOps接口{}".format(url_index + 1)
@staticmethod
def _next_quote_value(lines, start):
for index in range(start + 1, min(start + 8, len(lines))):
line = lines[index].strip()
if line.startswith(">"):
value = line[1:].strip()
if value and value != "暂无参数":
return value
return ""
@staticmethod
def _section_end(lines, start):
for index in range(start + 1, len(lines)):
line = lines[index].strip()
if line.startswith("## ") or line.startswith("### ") or line == "**接口URL**":
return index
return len(lines)
def _find_section_quote(self, lines, url_index, section_name):
start = max(0, url_index - 80)
end = self._section_end(lines, url_index)
for index in range(start, min(end, len(lines))):
if lines[index].strip() == section_name:
return self._next_quote_value(lines, index)
return None
def _find_code_block(self, lines, url_index, section_name):
end = self._section_end(lines, url_index)
for index in range(url_index, end):
if lines[index].strip() != section_name:
continue
for code_start in range(index + 1, end):
if lines[code_start].strip().startswith("```"):
block = []
for code_end in range(code_start + 1, end):
if lines[code_end].strip().startswith("```"):
return "\n".join(block).strip()
block.append(lines[code_end])
return ""
def _find_param_table(self, lines, url_index, section_name):
end = self._section_end(lines, url_index)
for index in range(url_index, end):
if lines[index].strip() != section_name:
continue
params = {}
for row_index in range(index + 1, end):
row = lines[row_index].strip()
if not row.startswith("|"):
if params:
break
continue
if "---" in row or "参数名" in row or "暂无参数" in row:
continue
columns = [column.strip() for column in row.strip("|").split("|")]
if len(columns) >= 2 and columns[0]:
params[columns[0]] = self._coerce_value(columns[1])
return params
return {}
def _find_headers(self, lines, url_index):
headers = {}
end = self._section_end(lines, url_index)
for index in range(url_index, end):
if lines[index].strip() != "**请求Header参数**":
continue
table_started = False
for row_index in range(index + 1, end):
row = lines[row_index].strip()
if row.startswith("**") or row.startswith("#") or row.startswith("*"):
break
if not row.startswith("|"):
if table_started:
break
continue
table_started = True
if "---" in row or "参数名" in row or "暂无参数" in row:
continue
columns = [column.strip() for column in row.strip("|").split("|")]
if len(columns) >= 2 and columns[0] and columns[0] != "Authorization":
headers[columns[0]] = columns[1]
return headers
return headers
@classmethod
def _parse_body(cls, body_text):
if not body_text or body_text == "暂无数据":
return {}
text = cls._clean_body(body_text)
for loader in (json.loads, ast.literal_eval):
try:
value = loader(text)
return value if isinstance(value, (dict, list)) else {}
except Exception:
pass
return {}
@staticmethod
def _clean_body(text):
text = re.sub(r"//.*", "", text)
text = re.sub(r"/\*.*?\*/", "", text, flags=re.S)
text = text.replace("{{token}}", "")
text = re.sub(r",\s*([}\]])", r"\1", text)
return text.strip()
@staticmethod
def _coerce_value(value):
if value in ("-", "暂无参数", ""):
return ""
if value.isdigit():
return int(value)
if value.lower() in ("true", "false"):
return value.lower() == "true"
return value
def load_hubops_cases():
return HubOpsParser().parse()

View File

@@ -0,0 +1,131 @@
# -*- coding: utf-8 -*-
import json
import logging
import os
from urllib.parse import urljoin
import allure
import requests
from joyhub_backend.library.auth import JoyhubAuth, TIMEOUT
MANAGER_BASE_URL = os.getenv(
"JOYHUB_MANAGER_BASE_URL",
"http://test-manager-api.best-envision.com",
)
class JoyhubInterface(object):
def __init__(self):
self.auth = JoyhubAuth()
self.session = requests.Session()
self.base_url = MANAGER_BASE_URL.rstrip("/") + "/"
def request(self, case_name, method, path, body=None, query=None, headers=None, expected_code=0):
url = path if path.startswith("http") else urljoin(self.base_url, path.lstrip("/"))
request_headers = self.auth.auth_headers()
if headers:
request_headers.update({key: value for key, value in headers.items() if key.lower() != "authorization"})
with allure.step("操作步骤:{}".format(case_name)):
self._attach_request(url, method, request_headers, body, query)
logging.info("case: %s", case_name)
logging.info("request url: %s", url)
logging.info("request method: %s", method)
logging.info("request headers: %s", request_headers)
logging.info("request body: %s", body)
logging.info("request query: %s", query)
request_kwargs = {
"method": method,
"url": url,
"params": query,
"headers": request_headers,
"timeout": TIMEOUT,
}
if method.upper() in ("POST", "PUT", "PATCH"):
request_kwargs["json"] = body
elif body:
request_kwargs["params"] = dict(query or {}, **body) if isinstance(body, dict) else query
response = self.session.request(**request_kwargs)
self._attach_response(response)
self._attach_log(case_name, url, method, request_headers, body, query, response)
logging.info("response status: %s", response.status_code)
logging.info("response body: %s", response.text)
is_business_api = self._is_business_api(url)
assertion_text = "HTTP状态码为200且业务code为{}".format(expected_code) if is_business_api else "HTTP状态码为2xx兼容非JSON/SSE响应"
allure.attach(assertion_text, "断言内容", allure.attachment_type.TEXT)
with allure.step("断言内容:{}".format(assertion_text)):
try:
if is_business_api:
assert response.status_code == 200, "HTTP状态码期望200实际{},响应{}".format(
response.status_code, response.text
)
data = self._safe_json(response)
assert isinstance(data, dict), "业务接口响应不是JSON对象响应{}".format(response.text)
assert "code" in data, "响应缺少 code 字段,响应{}".format(data)
assert data.get("code") == expected_code, "业务code期望{},实际{}msg={},响应{}".format(
expected_code, data.get("code"), data.get("msg"), data
)
else:
assert 200 <= response.status_code < 300, "HTTP状态码期望2xx实际{},响应{}".format(
response.status_code, response.text
)
data = self._non_json_result(response)
allure.attach("", "断言失败原因", allure.attachment_type.TEXT)
return data
except AssertionError as error:
allure.attach(str(error), "断言失败原因", allure.attachment_type.TEXT)
raise AssertionError("断言失败原因:{}".format(error))
@staticmethod
def _attach_request(url, method, headers, body, query):
allure.attach(str(url), "请求url", allure.attachment_type.TEXT)
allure.attach(str(method), "请求方式", allure.attachment_type.TEXT)
allure.attach(json.dumps(headers, ensure_ascii=False, indent=2), "请求头", allure.attachment_type.JSON)
allure.attach(json.dumps(query or {}, ensure_ascii=False, indent=2), "请求query", allure.attachment_type.JSON)
allure.attach(json.dumps(body or {}, ensure_ascii=False, indent=2), "请求体", allure.attachment_type.JSON)
@staticmethod
def _attach_response(response):
allure.attach(str(response.status_code), "响应状态码", allure.attachment_type.TEXT)
content_type = response.headers.get("Content-Type", "")
attachment_type = allure.attachment_type.JSON if "json" in content_type.lower() else allure.attachment_type.TEXT
allure.attach(response.text, "响应体/日志", attachment_type)
def _is_business_api(self, url):
return url.startswith(self.base_url)
@staticmethod
def _safe_json(response):
if not response.text.strip():
return None
return response.json()
@staticmethod
def _non_json_result(response):
try:
data = response.json()
except ValueError:
data = {
"status_code": response.status_code,
"content_type": response.headers.get("Content-Type", ""),
"text": response.text,
}
return data
@staticmethod
def _attach_log(case_name, url, method, headers, body, query, response):
log_text = "\n".join([
"case: {}".format(case_name),
"request url: {}".format(url),
"request method: {}".format(method),
"request headers: {}".format(headers),
"request query: {}".format(query or {}),
"request body: {}".format(body or {}),
"response status: {}".format(response.status_code),
"response body: {}".format(response.text),
])
allure.attach(log_text, "日志", allure.attachment_type.TEXT)

View File

@@ -0,0 +1,136 @@
# -*- coding: utf-8 -*-
import allure
import logging
import requests
import json
import pytest
try:
from joyhub_backend.library.joyhub_interface import JoyhubInterface
except Exception:
JoyhubInterface = None
logging.basicConfig(level=logging.INFO, format='%(asctime)s - %(levelname)s - %(message)s')
BASE_URL = "http://test-manager-api.best-envision.com"
API_PATH = "admin/video/getVideoLabelList"
CASE_NAME = "获取视频标签列表"
def _mask_sensitive(data):
if data is None:
return data
sensitive_keys = {"authorization", "token", "access_token", "accessToken", "cookie", "password", "passwd"}
if isinstance(data, dict):
masked = {}
for key, value in data.items():
if str(key).lower() in sensitive_keys:
masked[key] = "***MASKED***"
else:
masked[key] = _mask_sensitive(value)
return masked
if isinstance(data, list):
return [_mask_sensitive(item) for item in data]
return data
def _attach_json(name, data):
allure.attach(
json.dumps(_mask_sensitive(data), ensure_ascii=False, indent=2),
name,
allure.attachment_type.JSON
)
def _normalize_response(response):
if isinstance(response, requests.Response):
try:
response_json = response.json()
except ValueError:
response_json = None
return response.status_code, response_json, response.text
if isinstance(response, dict):
return 200, response, json.dumps(response, ensure_ascii=False)
return None, None, str(response)
@allure.feature("接口")
class TestHubopsVideoLabelList(object):
def setup_method(self):
logging.info("-----------------------------Test Start-------------------------------")
def teardown_method(self):
logging.info("-----------------------------Test End-------------------------------")
@allure.story("获取视频标签列表")
@allure.title("测试HubOps获取视频标签列表接口")
def test_get_video_label_list(self):
if JoyhubInterface is None:
pytest.skip("joyhub_backend项目优先使用JoyhubInterface鉴权封装请确认joyhub_backend.library.joyhub_interface.JoyhubInterface可导入")
with allure.step("1. 准备请求参数"):
method = "POST"
headers = {
"Content-Type": "application/json"
}
body = {
"page": 1,
"limit": 10,
"sort": "id",
"label_name": "",
"category_id": 0,
"video_type": 0,
"video_num": 0,
"created_at": [],
"order": "descending"
}
logging.info("请求方法: %s", method)
logging.info("请求路径: %s", API_PATH)
_attach_json("请求头", headers)
_attach_json("请求体", body)
with allure.step("2. 调用HubOps获取视频标签列表接口"):
client = JoyhubInterface()
response = client.request(
CASE_NAME,
method,
API_PATH,
body=body,
headers=headers,
expected_code=0
)
status_code, response_json, response_text = _normalize_response(response)
allure.attach(str(status_code), "HTTP状态码", allure.attachment_type.TEXT)
if response_json is not None:
_attach_json("响应JSON", response_json)
else:
allure.attach(response_text, "响应内容", allure.attachment_type.TEXT)
with allure.step("3. 校验响应基础字段"):
assert status_code == 200, "HTTP状态码应为200实际为: {}".format(status_code)
assert response_json is not None, "响应内容应为JSON且不能为空"
assert isinstance(response_json, dict), "响应JSON应为对象类型"
assert "code" in response_json, "响应JSON应包含code字段"
assert response_json.get("code") == 0, "响应code应为0实际为: {}".format(response_json.get("code"))
assert "msg" in response_json, "响应JSON应包含msg字段"
assert response_json.get("msg") is not None, "响应msg字段不应为None"
assert "data" in response_json, "响应JSON应包含data字段"
assert response_json.get("data") is not None, "响应data字段不应为None"
assert isinstance(response_json.get("data"), list), "响应data字段应为数组类型"
assert "count" in response_json, "响应JSON应包含count字段"
assert response_json.get("count") is not None, "响应count字段不应为None"

View File

@@ -0,0 +1,57 @@
# -*- coding: utf-8 -*-
import logging
import re
from urllib.parse import urljoin
import allure
import pytest
from joyhub_backend.library.hubops_parser import load_hubops_cases
from joyhub_backend.library.joyhub_interface import JoyhubInterface
ALL_CASES = load_hubops_cases()
def case_id(case):
raw_id = "{}-{}".format(case.get("case_id"), case.get("name"))
safe_id = re.sub(r"[^0-9A-Za-z_\u4e00-\u9fa5-]+", "_", raw_id)
return safe_id[:120]
@allure.feature("JoyHub Backend 接口自动化")
class TestHubOps(object):
test_case = JoyhubInterface()
def teardown_method(self):
with allure.step("后置:记录用例结束日志"):
logging.info("-----------------------------End-------------------------------")
@pytest.mark.parametrize("case", ALL_CASES, ids=case_id)
def test_hub_ops_api(self, case):
allure.dynamic.title("{} {}".format(case.get("case_id"), case.get("name")))
allure.dynamic.story(case.get("name"))
with allure.step("前置:初始化接口客户端并准备鉴权 token"):
headers = case.get("headers") or {}
query = case.get("query") or {}
body = case.get("body") or {}
response_data = self.test_case.request(
case.get("name"),
case.get("method"),
case.get("url"),
body=body,
query=query,
headers=headers,
)
with allure.step("断言内容:校验响应基础字段"):
request_url = case.get("url") or ""
full_url = request_url if request_url.startswith("http") else urljoin(self.test_case.base_url, request_url.lstrip("/"))
if full_url.startswith(self.test_case.base_url):
assert isinstance(response_data, dict), "断言失败原因响应不是JSON对象响应{}".format(response_data)
assert "code" in response_data, "断言失败原因响应缺少code字段响应{}".format(response_data)
assert response_data.get("msg") is not None, "断言失败原因msg字段不能为空响应{}".format(response_data)
else:
assert response_data is not None, "断言失败原因:非业务接口响应为空"
logging.info("断言通过:%s", case.get("name"))

View File

View File

@@ -0,0 +1,170 @@
# -*- coding: utf-8 -*-
import argparse
import os
import shutil
import subprocess
import sys
current_file_path = os.path.abspath(__file__)
project_root = os.path.abspath(os.path.join(os.path.dirname(current_file_path), '../../'))
if project_root not in sys.path:
sys.path.insert(0, project_root)
TEST_CASE_DIR = 'joyhub_backend/test_case/TestCase'
case_dir = os.path.join(project_root, TEST_CASE_DIR)
ALLURE_RESULTS_DIR = os.path.join(project_root, 'joyhub_backend', 'test_case', 'reports', 'allure-results')
ALLURE_REPORT_DIR = os.path.join(project_root, 'joyhub_backend', 'test_case', 'reports', 'allure-report')
def ensure_dirs():
os.makedirs(ALLURE_RESULTS_DIR, exist_ok=True)
os.makedirs(ALLURE_REPORT_DIR, exist_ok=True)
def clean_allure_results():
if os.path.exists(ALLURE_RESULTS_DIR):
shutil.rmtree(ALLURE_RESULTS_DIR)
os.makedirs(ALLURE_RESULTS_DIR, exist_ok=True)
def find_test_files(directory):
test_files = []
for root, dirs, files in os.walk(directory):
for file in files:
if file.endswith('.py') and not file.startswith('__') and file != 'conftest.py':
test_files.append(os.path.join(root, file))
return test_files
def run_pytest(args_list):
env = os.environ.copy()
env['PYTHONPATH'] = project_root + (os.pathsep + env['PYTHONPATH'] if 'PYTHONPATH' in env else '')
cmd = ['python', '-m', 'pytest'] + args_list
print("开始执行pytest...")
print("执行命令: {}".format(' '.join('"{}"'.format(item) if ' ' in item else item for item in cmd)), flush=True)
result = subprocess.run(cmd, cwd=project_root, env=env)
print("pytest执行结束退出码: {}".format(result.returncode), flush=True)
return result.returncode
def run_tests(target=None, test_type='all'):
base_args = ['-v', '--tb=short', '--alluredir={}'.format(ALLURE_RESULTS_DIR)]
if test_type == 'all':
print("运行所有测试用例...")
test_files = find_test_files(case_dir)
if not test_files:
print("错误: 未找到测试文件")
return 1
args = test_files + base_args
elif test_type == 'dir':
full_path = os.path.join(case_dir, target.replace('/', os.sep).replace('\\', os.sep))
if not os.path.exists(full_path):
print("错误: 目录不存在: {}".format(full_path))
return 1
print("按目录运行: {}".format(target))
test_files = find_test_files(full_path)
if not test_files:
print("错误: 未找到测试文件")
return 1
args = test_files + base_args
elif test_type == 'file':
full_path = os.path.join(case_dir, target.replace('/', os.sep).replace('\\', os.sep))
if not os.path.exists(full_path):
print("错误: 文件不存在: {}".format(full_path))
return 1
print("按文件运行: {}".format(target))
args = [full_path] + base_args
elif test_type == 'keyword':
print("按关键字运行: {}".format(target))
test_files = find_test_files(case_dir)
args = test_files + ['-k={}'.format(target)] + base_args
elif test_type == 'marker':
print("按pytest标记运行: {}".format(target))
test_files = find_test_files(case_dir)
args = test_files + ['-m={}'.format(target)] + base_args
elif test_type == 'feature':
print("按Allure feature运行: {}".format(target))
test_files = find_test_files(case_dir)
args = test_files + ['--allure-features={}'.format(target)] + base_args
elif test_type == 'story':
print("按Allure story运行: {}".format(target))
test_files = find_test_files(case_dir)
args = test_files + ['--allure-stories={}'.format(target)] + base_args
else:
print("错误: 未知的测试类型: {}".format(test_type))
return 1
return run_pytest(args)
def generate_allure_report():
print("开始生成Allure报告...", flush=True)
if os.path.exists(ALLURE_REPORT_DIR):
shutil.rmtree(ALLURE_REPORT_DIR)
cmd = 'allure generate "{}" --output "{}"'.format(ALLURE_RESULTS_DIR, ALLURE_REPORT_DIR)
print("执行命令: {}".format(cmd), flush=True)
try:
subprocess.run(cmd, check=True, shell=True)
print("Allure报告生成成功: {}".format(ALLURE_REPORT_DIR))
print("打开报告命令: allure open \"{}\"".format(ALLURE_REPORT_DIR))
return 0
except (subprocess.CalledProcessError, FileNotFoundError, OSError) as error:
print("生成Allure报告失败: {}".format(error))
print("手动执行: {}".format(cmd))
return 1
def open_allure_report():
cmd = 'allure open "{}"'.format(ALLURE_REPORT_DIR)
try:
subprocess.Popen(cmd, shell=True)
print("Allure报告已打开: {}".format(ALLURE_REPORT_DIR))
return 0
except (FileNotFoundError, OSError) as error:
print("打开Allure报告失败: {}".format(error))
return 1
def main():
parser = argparse.ArgumentParser(description='JoyHub Backend 接口自动化测试执行工具')
run_group = parser.add_mutually_exclusive_group(required=False)
run_group.add_argument('--feature', type=str, help='按Allure feature运行')
run_group.add_argument('--story', type=str, help='按Allure story运行')
run_group.add_argument('--dir', type=str, help='按目录运行相对于TestCase目录')
run_group.add_argument('--file', type=str, help='按文件运行相对于TestCase目录')
run_group.add_argument('--keyword', type=str, help='按关键字运行')
run_group.add_argument('--marker', type=str, help='按pytest标记运行')
parser.add_argument('--report', action='store_true', help='生成Allure报告')
parser.add_argument('--open', action='store_true', help='打开Allure报告')
parser.add_argument('--no-report', action='store_true', help='不生成Allure报告')
args = parser.parse_args()
ensure_dirs()
clean_allure_results()
if args.feature:
exit_code = run_tests(args.feature, 'feature')
elif args.story:
exit_code = run_tests(args.story, 'story')
elif args.dir:
exit_code = run_tests(args.dir, 'dir')
elif args.file:
exit_code = run_tests(args.file, 'file')
elif args.keyword:
exit_code = run_tests(args.keyword, 'keyword')
elif args.marker:
exit_code = run_tests(args.marker, 'marker')
else:
exit_code = run_tests()
if args.report or not args.no_report:
generate_allure_report()
if args.open:
open_allure_report()
print("=" * 80)
print("测试执行完成" if exit_code == 0 else "测试执行失败,退出码: {}".format(exit_code))
print("=" * 80)
sys.exit(exit_code)
if __name__ == '__main__':
main()