refactor(api): yun8_sync 并入 tests 分层框架(接口层 apis/yun8 + 用例层 api + Allure)
- tests/core: 新增 BaseHttpClient 统一 HTTP 封装(直连绕行/超时/日志/自定义编码); db_helper 追加 Db 长连接类(事务/mogrify/自动重连),既有函数不动 - tests/apis/yun8: 21 个 step 方法按域拆分为 5 个 Mixin(driver/accounting/ audit_payment/receipt/third_party),Yun8Pipeline 24 步编排原样迁移, SQL 全部参数化并集中至 sql_templates - tests/api: 5 阶段有序 P0 E2E 用例(业务失败由 print 改为真实报错)+ README 迁移(删除明文密码段,修正步骤编号漂移) - yun8_sync 目录退役:配置并入 test_config.yaml 的 yun8_api 段; 造数 CLI 保留(python -m tests.apis.yun8.cli,兼容旧 config.yaml 结构) - pytest.ini 注册 api/yun8_sync marker;requirements.txt 补 requests>=2.28 - 静态验证: compileall / 全仓 collect 24 条 / marker 过滤 / CLI --help / 客户端与 pipeline 离线实例化 / SQL 占位符核对 / cross_end 兼容
This commit is contained in:
@@ -10,6 +10,8 @@ Usage:
|
||||
|
||||
import pymysql
|
||||
|
||||
from contextlib import contextmanager
|
||||
|
||||
from tests.core.config_loader import load_config
|
||||
|
||||
|
||||
@@ -73,3 +75,77 @@ def update_risk_control_status(order_code: str, status: int = 1, config: dict |
|
||||
")"
|
||||
)
|
||||
return execute_sql(sql, (status, order_code), config)
|
||||
|
||||
|
||||
class Db:
|
||||
"""长连接数据库客户端 — 用例/pipeline 级复用。
|
||||
|
||||
与上面的模块级函数(每语句新建连接)不同,本类在一个实例内复用单条连接:
|
||||
- execute/query 前自动 ping 重连,规避长空闲连接被服务端断开
|
||||
- 事务外 execute 保持逐语句提交(与旧行为一致)
|
||||
- transaction() 内的 execute 不再单独提交,由上下文统一 commit/rollback
|
||||
- mogrify() 渲染参数化 SQL 为完整语句(dry-run 预览用,不执行)
|
||||
|
||||
Usage:
|
||||
db = Db() # 不传 config 则读 yaml database 段
|
||||
affected = db.execute("UPDATE t SET a = %s WHERE code = %s", (1, code))
|
||||
rows = db.query("SELECT * FROM t WHERE code = %s", (code,))
|
||||
with db.transaction():
|
||||
db.execute(sql1)
|
||||
db.execute(sql2)
|
||||
db.close()
|
||||
"""
|
||||
|
||||
def __init__(self, config: dict | None = None):
|
||||
self._kwargs = _resolve_config(config)
|
||||
self._conn: pymysql.connections.Connection | None = None
|
||||
self._in_transaction = False
|
||||
|
||||
def _connection(self):
|
||||
if self._conn is None:
|
||||
self._conn = pymysql.connect(**self._kwargs)
|
||||
else:
|
||||
self._conn.ping(reconnect=True)
|
||||
return self._conn
|
||||
|
||||
def execute(self, sql: str, params: tuple | None = None) -> int:
|
||||
"""执行写语句,返回影响行数。事务内调用不单独提交。"""
|
||||
conn = self._connection()
|
||||
with conn.cursor() as cursor:
|
||||
affected = cursor.execute(sql, params)
|
||||
if not self._in_transaction:
|
||||
conn.commit()
|
||||
return affected
|
||||
|
||||
def query(self, sql: str, params: tuple | None = None) -> list[dict]:
|
||||
"""执行查询,返回 DictCursor 结果列表。"""
|
||||
conn = self._connection()
|
||||
with conn.cursor(pymysql.cursors.DictCursor) as cursor:
|
||||
cursor.execute(sql, params)
|
||||
return cursor.fetchall()
|
||||
|
||||
@contextmanager
|
||||
def transaction(self):
|
||||
"""事务上下文:块内所有 execute 在退出时统一提交,异常统一回滚。"""
|
||||
conn = self._connection()
|
||||
self._in_transaction = True
|
||||
try:
|
||||
yield conn
|
||||
conn.commit()
|
||||
except Exception:
|
||||
conn.rollback()
|
||||
raise
|
||||
finally:
|
||||
self._in_transaction = False
|
||||
|
||||
def mogrify(self, sql: str, params: tuple | None = None) -> str:
|
||||
"""渲染参数化 SQL 为最终语句(含引号转义),仅预览不执行。"""
|
||||
conn = self._connection()
|
||||
with conn.cursor() as cursor:
|
||||
return cursor.mogrify(sql, params)
|
||||
|
||||
def close(self) -> None:
|
||||
"""关闭连接(幂等,可重复调用)。"""
|
||||
if self._conn is not None:
|
||||
self._conn.close()
|
||||
self._conn = None
|
||||
|
||||
@@ -0,0 +1,118 @@
|
||||
"""统一 HTTP 客户端 — requests.Session 封装。
|
||||
|
||||
所有 API 客户端(tests/apis/)共用此基类,职责:
|
||||
- 单 Session 连接复用 + 请求头管理
|
||||
- 直连服务器(禁用系统代理),避免本地代理篡改请求触发服务端 WAF 误判
|
||||
- 统一超时 / verify_ssl / 请求响应日志(INFO 摘要、DEBUG 全量)
|
||||
- 统一响应处理:非 2xx 抛 HTTPError,成功返回解析后的 JSON dict
|
||||
|
||||
Usage:
|
||||
from tests.core.http_client import BaseHttpClient
|
||||
|
||||
client = BaseHttpClient("https://api.example.com",
|
||||
headers={"deviceid": "xxx"}, timeout=60)
|
||||
data = client.get("/v1/resource", params={"id": 1})
|
||||
client.set_header("authorization", "Bearer xxx")
|
||||
"""
|
||||
|
||||
import logging
|
||||
import urllib.parse
|
||||
|
||||
import requests
|
||||
|
||||
# INFO 日志中 path 的最大长度(避免超长 query string 刷屏)
|
||||
_PATH_LOG_LIMIT = 200
|
||||
|
||||
|
||||
class BaseHttpClient:
|
||||
"""单 Session HTTP 客户端基类。
|
||||
|
||||
Args:
|
||||
base_url: 服务根地址,如 "https://ybxcx.ynyun8.com:8000"
|
||||
headers: 初始请求头(跳过空值,与旧 _init_session 行为一致)
|
||||
verify_ssl: 是否校验 HTTPS 证书(测试环境自签证书默认关闭)
|
||||
timeout: 单请求超时秒数;None 表示不限制(旧版行为)
|
||||
name: 日志器后缀,用于区分多套会话(driver/admin/tp)
|
||||
"""
|
||||
|
||||
def __init__(self, base_url: str, headers: dict | None = None,
|
||||
verify_ssl: bool = False, timeout: float | None = None,
|
||||
name: str = "http"):
|
||||
self.base_url = base_url.rstrip("/")
|
||||
self.verify_ssl = verify_ssl
|
||||
self.timeout = timeout
|
||||
self.log = logging.getLogger(f"tests.core.http_client.{name}")
|
||||
|
||||
self.session = requests.Session()
|
||||
for key, value in (headers or {}).items():
|
||||
if value:
|
||||
self.session.headers[key] = value
|
||||
# 禁用系统代理,直连服务器(避免本地代理修改请求导致 WAF 误判 SQL 注入)
|
||||
self.session.trust_env = False
|
||||
self.session.proxies = {"http": None, "https": None}
|
||||
|
||||
# ── 请求头管理 ────────────────────────────────────────────────
|
||||
def set_header(self, key: str, value: str) -> None:
|
||||
"""设置/覆盖一个请求头(允许空值,与旧版无条件覆盖行为一致)。"""
|
||||
self.session.headers[key] = value
|
||||
|
||||
def get_header(self, key: str, default: str = "") -> str:
|
||||
"""读取当前会话的一个请求头。"""
|
||||
return self.session.headers.get(key, default)
|
||||
|
||||
# ── 请求方法(返回解析后的 JSON dict)─────────────────────────
|
||||
def get(self, path: str, params: dict | None = None) -> dict:
|
||||
return self._request("GET", f"{self.base_url}{path}", params=params)
|
||||
|
||||
def post(self, path: str, params: dict | None = None,
|
||||
data: dict | None = None) -> dict:
|
||||
return self._request("POST", f"{self.base_url}{path}",
|
||||
params=params, data=data)
|
||||
|
||||
def put(self, path: str, params: dict | None = None,
|
||||
data: dict | None = None) -> dict:
|
||||
return self._request("PUT", f"{self.base_url}{path}",
|
||||
params=params, data=data)
|
||||
|
||||
def post_json(self, path: str, body: dict,
|
||||
params: dict | None = None) -> dict:
|
||||
"""POST JSON 请求体(Content-Type: application/json)。"""
|
||||
return self._request("POST", f"{self.base_url}{path}",
|
||||
params=params, json_body=body)
|
||||
|
||||
def request_with_encoded_query(self, method: str, path: str,
|
||||
params: dict) -> dict:
|
||||
"""手工构建 query string 的请求(GET/POST/PUT)。
|
||||
|
||||
与 get/post/put(params=...) 的区别:query string 由 urlencode 一次性
|
||||
编码后拼接为完整 URL 再发给 requests,避免 requests 内部再次编码导致
|
||||
URL 值中已有的 %XX(如 AWS Signature 的 %2B)被二次转义为 %25XX,
|
||||
触发服务端 WAF 的 SQL 注入误判。
|
||||
"""
|
||||
qs = urllib.parse.urlencode(params, doseq=True,
|
||||
quote_via=urllib.parse.quote)
|
||||
full_url = f"{self.base_url}{path}?{qs}"
|
||||
self.log.debug("%s 全量 URL: %s", method, full_url)
|
||||
return self._request(method, full_url)
|
||||
|
||||
# ── 内部实现 ─────────────────────────────────────────────────
|
||||
def _request(self, method: str, url: str, params: dict | None = None,
|
||||
data: dict | None = None, json_body: dict | None = None) -> dict:
|
||||
display_path = url[len(self.base_url):][: _PATH_LOG_LIMIT] or "/"
|
||||
self.log.info("%s %s", method, display_path)
|
||||
resp = self.session.request(
|
||||
method, url,
|
||||
params=params, data=data, json=json_body,
|
||||
verify=self.verify_ssl, timeout=self.timeout,
|
||||
)
|
||||
resp.raise_for_status()
|
||||
payload = resp.json()
|
||||
code = payload.get("code") if isinstance(payload, dict) else None
|
||||
msg = payload.get("msg", "") if isinstance(payload, dict) else ""
|
||||
self.log.info(" ← %s (%.2fs) code=%s msg=%s",
|
||||
resp.status_code, resp.elapsed.total_seconds(), code, msg)
|
||||
return payload
|
||||
|
||||
def close(self) -> None:
|
||||
"""关闭底层连接池。"""
|
||||
self.session.close()
|
||||
Reference in New Issue
Block a user