Files
Neo-ZQYY/scripts/ops/_run_auth_pbt_full.py

545 lines
20 KiB
Python
Raw Permalink 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.
# -*- coding: utf-8 -*-
"""
认证系统属性测试全量运行脚本100 次迭代)
背景:
Spec: 03-miniapp-auth-system小程序用户认证系统
任务 11: 属性测试全量运行
前面各任务中的属性测试仅用 5 次迭代快速验证逻辑正确性,
本脚本集中对所有 15 个属性测试执行 100 次迭代,确保健壮性。
系统概述:
NeoZQYY 台球门店全栈数据平台的小程序认证系统,涵盖:
- 微信登录code2Session → openid → JWT
- 用户申请审核site_code 映射、人员匹配、角色分配)
- RBAC 权限控制(多店铺隔离、权限中间件)
- JWT 令牌管理(受限令牌、店铺切换、过期拒绝)
数据库:
测试库 test_zqyy_app通过 APP_DB_DSN 环境变量连接)
禁止连接正式库 zqyy_app
运行方式:
cd C:\\NeoZQYY
python scripts/ops/_run_auth_pbt_full.py [--concurrency N] [--only P1,P2,...] [--skip P3,P5]
设计要求:
1. 后台逐个运行每个属性测试subprocess前台定时监控
2. 控制数据库并发:同一时刻只运行 1 个测试(默认),避免占满连接
3. 每完成一个测试输出进度
4. 全部完成后生成详细 MD 格式报告
"""
import os
import sys
import subprocess
import time
import json
import argparse
from datetime import datetime, timezone, timedelta
from pathlib import Path
# ── 环境初始化 ──────────────────────────────────────────────────
from dotenv import load_dotenv
PROJECT_ROOT = Path(__file__).resolve().parent.parent.parent
load_dotenv(PROJECT_ROOT / ".env")
APP_DB_DSN = os.environ.get("APP_DB_DSN")
if not APP_DB_DSN:
print("❌ APP_DB_DSN 环境变量未设置,无法运行属性测试。")
sys.exit(1)
# ── 测试定义 ──────────────────────────────────────────────────
# 每个属性测试的 pytest 节点 ID 和描述
PROPERTY_TESTS = [
{
"id": "P1",
"name": "迁移脚本幂等性",
"class": "TestProperty1MigrationIdempotency",
"validates": "Requirements 1.9, 2.4, 11.5",
"description": "对认证系统迁移脚本DDL + 种子数据)连续执行两次,验证幂等性",
"db_write": True, # 标记是否写数据库
},
{
"id": "P2",
"name": "登录创建/查找用户",
"class": "TestProperty2LoginCreateFindUser",
"validates": "Requirements 3.2, 3.3",
"description": "随机 openid 登录验证新用户创建pending和已有用户查找",
"db_write": True,
},
{
"id": "P3",
"name": "disabled 用户登录拒绝",
"class": "TestProperty3DisabledUserLoginRejection",
"validates": "Requirements 3.5",
"description": "disabled 状态用户登录返回 403不签发 JWT",
"db_write": True,
},
{
"id": "P4",
"name": "申请创建正确性",
"class": "TestProperty4ApplicationCreation",
"validates": "Requirements 4.1, 4.2, 4.3, 4.4",
"description": "随机合法申请数据,验证申请记录创建和 site_code 映射",
"db_write": True,
},
{
"id": "P5",
"name": "手机号格式验证",
"class": "TestProperty5PhoneFormatValidation",
"validates": "Requirements 4.5",
"description": "随机非法手机号提交申请,验证 422 拒绝",
"db_write": False,
},
{
"id": "P6",
"name": "重复申请拒绝",
"class": "TestProperty6DuplicateApplicationRejection",
"validates": "Requirements 4.6",
"description": "已有 pending 申请的用户再次提交,验证 409 拒绝",
"db_write": True,
},
{
"id": "P7",
"name": "人员匹配合并正确性",
"class": "TestProperty7MatchingMerge",
"validates": "Requirements 5.1, 5.2, 5.3, 5.4",
"description": "随机 site_id + phone 组合,验证助教/员工匹配结果合并",
"db_write": False,
},
{
"id": "P8",
"name": "审核操作正确性",
"class": "TestProperty8ReviewOperations",
"validates": "Requirements 6.1, 6.2, 6.3, 6.4, 6.5",
"description": "随机 pending 申请执行批准/拒绝,验证状态流转和绑定创建",
"db_write": True,
},
{
"id": "P9",
"name": "非 pending 审核拒绝",
"class": "TestProperty9NonPendingReviewRejection",
"validates": "Requirements 6.6",
"description": "非 pending 状态申请执行审核,验证 409 拒绝",
"db_write": True,
},
{
"id": "P10",
"name": "用户状态查询完整性",
"class": "TestProperty10UserStatusQueryCompleteness",
"validates": "Requirements 7.1, 7.2",
"description": "随机用户状态组合,验证查询返回完整的申请列表和店铺信息",
"db_write": True,
},
{
"id": "P11",
"name": "多店铺角色独立分配",
"class": "TestProperty11MultiSiteRoleIndependence",
"validates": "Requirements 8.1",
"description": "随机用户 + 多 site_id验证角色独立分配互不干扰",
"db_write": True,
},
{
"id": "P12",
"name": "店铺切换令牌正确性",
"class": "TestProperty12SiteSwitchTokenCorrectness",
"validates": "Requirements 8.2, 10.4",
"description": "多店铺用户切换店铺,验证新 JWT 中 site_id 和 roles 正确",
"db_write": True,
},
{
"id": "P13",
"name": "权限中间件拦截正确性",
"class": "TestProperty13PermissionMiddleware",
"validates": "Requirements 8.3, 9.1, 9.2, 9.3",
"description": "随机用户 + 权限组合,验证中间件拦截/放行逻辑",
"db_write": True,
},
{
"id": "P14",
"name": "JWT payload 结构一致性",
"class": "TestProperty14JwtPayloadStructure",
"validates": "Requirements 10.1, 10.2, 10.3",
"description": "随机用户状态签发 JWT验证 payload 字段与状态一致",
"db_write": False,
},
{
"id": "P15",
"name": "JWT 过期/无效拒绝",
"class": "TestProperty15JwtExpiredInvalidRejection",
"validates": "Requirements 9.4",
"description": "随机过期/篡改/错密钥/垃圾 JWT验证 401 拒绝",
"db_write": False,
},
]
TEST_FILE = "tests/test_auth_system_properties.py"
MAX_EXAMPLES = 100
# ── 时区 ──────────────────────────────────────────────────────
CST = timezone(timedelta(hours=8))
def _now_cst() -> datetime:
return datetime.now(CST)
def _fmt(dt: datetime) -> str:
return dt.strftime("%Y-%m-%d %H:%M:%S")
# ── 运行单个测试 ─────────────────────────────────────────────
def run_single_test(prop: dict, max_examples: int = MAX_EXAMPLES) -> dict:
"""
运行单个属性测试,返回结果字典。
通过 HYPOTHESIS_MAX_EXAMPLES 环境变量覆盖迭代次数。
"""
node_id = f"{TEST_FILE}::{prop['class']}"
start = _now_cst()
print(f"\n{'='*60}")
print(f"▶ [{prop['id']}] {prop['name']} (max_examples={max_examples})")
print(f" 节点: {node_id}")
print(f" 开始: {_fmt(start)}")
print(f"{'='*60}")
env = os.environ.copy()
# hypothesis 通过 settings profile 或环境变量控制
# 我们用 --override-ini 传递 hypothesis settings
cmd = [
sys.executable, "-m", "pytest",
node_id,
"-v",
"--tb=short",
f"--hypothesis-seed=0", # 固定种子保证可复现
"-x", # 遇到第一个失败就停止(节省时间)
f"-o", f"hypothesis_settings_max_examples={max_examples}",
]
# hypothesis 不支持 pytest -o 覆盖 max_examples
# 改用环境变量 + conftest 或直接 patch
# 实际方案:通过 HYPOTHESIS_MAX_EXAMPLES 环境变量
env["HYPOTHESIS_MAX_EXAMPLES"] = str(max_examples)
result = subprocess.run(
cmd,
capture_output=True,
text=True,
cwd=str(PROJECT_ROOT),
env=env,
timeout=600, # 单个测试最多 10 分钟
)
end = _now_cst()
duration = (end - start).total_seconds()
# 解析结果
passed = result.returncode == 0
stdout = result.stdout or ""
stderr = result.stderr or ""
# 提取测试计数
test_count = _extract_test_count(stdout)
outcome = {
"id": prop["id"],
"name": prop["name"],
"class": prop["class"],
"validates": prop["validates"],
"description": prop["description"],
"db_write": prop["db_write"],
"passed": passed,
"returncode": result.returncode,
"duration_sec": round(duration, 1),
"start_time": _fmt(start),
"end_time": _fmt(end),
"test_count": test_count,
"stdout_tail": _tail(stdout, 30),
"stderr_tail": _tail(stderr, 10),
}
# 实时进度输出
status = "✅ PASSED" if passed else "❌ FAILED"
print(f"\n 结果: {status} 耗时: {duration:.1f}s 测试数: {test_count}")
if not passed:
print(f" --- stdout 尾部 ---")
print(outcome["stdout_tail"])
if stderr.strip():
print(f" --- stderr 尾部 ---")
print(outcome["stderr_tail"])
return outcome
def _extract_test_count(stdout: str) -> str:
"""从 pytest 输出中提取测试计数,如 '3 passed''2 passed, 1 failed'"""
for line in reversed(stdout.splitlines()):
line = line.strip()
if "passed" in line or "failed" in line or "error" in line:
# 去掉 ANSI 颜色码
import re
clean = re.sub(r'\x1b\[[0-9;]*m', '', line)
if "=" in clean:
return clean.split("=")[-1].strip().rstrip("=").strip()
return "unknown"
def _tail(text: str, n: int) -> str:
"""取文本最后 n 行"""
lines = text.splitlines()
return "\n".join(lines[-n:]) if len(lines) > n else text
# ── 生成 MD 报告 ─────────────────────────────────────────────
def generate_report(results: list[dict], total_start: datetime, total_end: datetime) -> str:
"""生成详细的 MD 格式测试报告"""
total_duration = (total_end - total_start).total_seconds()
passed_count = sum(1 for r in results if r["passed"])
failed_count = len(results) - passed_count
all_passed = failed_count == 0
lines = []
lines.append(f"# 认证系统属性测试全量报告")
lines.append(f"")
lines.append(f"- Spec: `03-miniapp-auth-system`(小程序用户认证系统)")
lines.append(f"- 任务: 11. 属性测试全量运行({MAX_EXAMPLES} 次迭代)")
lines.append(f"- 测试文件: `{TEST_FILE}`")
lines.append(f"- 数据库: `test_zqyy_app`(通过 `APP_DB_DSN`")
lines.append(f"- 运行时间: {_fmt(total_start)}{_fmt(total_end)}(共 {total_duration:.0f}s")
lines.append(f"- 总体结果: {'✅ 全部通过' if all_passed else f'{failed_count} 个失败'}")
lines.append(f"- 通过/总数: {passed_count}/{len(results)}")
lines.append(f"")
# 汇总表
lines.append(f"## 汇总")
lines.append(f"")
lines.append(f"| # | 属性 | 验证需求 | 结果 | 耗时 | 测试数 | 写库 |")
lines.append(f"|---|------|---------|------|------|--------|------|")
for r in results:
status = "" if r["passed"] else ""
db = "" if r["db_write"] else ""
lines.append(
f"| {r['id']} | {r['name']} | {r['validates']} | "
f"{status} | {r['duration_sec']}s | {r['test_count']} | {db} |"
)
lines.append(f"")
# 失败详情
failed = [r for r in results if not r["passed"]]
if failed:
lines.append(f"## 失败详情")
lines.append(f"")
for r in failed:
lines.append(f"### {r['id']} {r['name']}")
lines.append(f"")
lines.append(f"- 验证需求: {r['validates']}")
lines.append(f"- 描述: {r['description']}")
lines.append(f"- 返回码: {r['returncode']}")
lines.append(f"- 耗时: {r['duration_sec']}s")
lines.append(f"")
lines.append(f"```")
lines.append(r["stdout_tail"])
lines.append(f"```")
if r["stderr_tail"].strip():
lines.append(f"")
lines.append(f"stderr:")
lines.append(f"```")
lines.append(r["stderr_tail"])
lines.append(f"```")
lines.append(f"")
# 各测试详情
lines.append(f"## 各属性测试详情")
lines.append(f"")
for r in results:
status = "✅ PASSED" if r["passed"] else "❌ FAILED"
lines.append(f"### {r['id']} {r['name']}{status}")
lines.append(f"")
lines.append(f"- 描述: {r['description']}")
lines.append(f"- 验证需求: {r['validates']}")
lines.append(f"- 测试类: `{r['class']}`")
lines.append(f"- 开始: {r['start_time']} 结束: {r['end_time']}")
lines.append(f"- 耗时: {r['duration_sec']}s")
lines.append(f"- 测试数: {r['test_count']}")
lines.append(f"- 写库: {'' if r['db_write'] else ''}")
lines.append(f"")
# 数据库带宽说明
lines.append(f"## 数据库资源控制")
lines.append(f"")
lines.append(f"- 串行执行:同一时刻仅运行 1 个属性测试,避免数据库连接争用")
lines.append(f"- 写库测试({sum(1 for r in results if r['db_write'])} 个)每个测试内部自行清理测试数据")
lines.append(f"- 纯读/内存测试({sum(1 for r in results if not r['db_write'])} 个)不产生数据库写入")
lines.append(f"- 测试间无并发,为其他调试任务保留数据库带宽")
lines.append(f"")
return "\n".join(lines)
# ── conftest 补丁 ─────────────────────────────────────────────
CONFTEST_PATCH = '''# -*- coding: utf-8 -*-
"""
临时 conftest通过环境变量覆盖 hypothesis max_examples。
由 _run_auth_pbt_full.py 自动生成,测试完成后自动删除。
原理:在 pytest 收集完测试后,遍历所有 hypothesis 测试项,
替换其 settings 中的 max_examples 为环境变量指定的值。
"""
import os
_max_env = os.environ.get("HYPOTHESIS_MAX_EXAMPLES")
def pytest_collection_modifyitems(items):
"""收集完测试后,覆盖 hypothesis settings 中的 max_examples"""
if not _max_env:
return
forced_max = int(_max_env)
from hypothesis import settings as _settings
for item in items:
# hypothesis 测试会在 item 上挂 hypothesis_settings
if hasattr(item, "_hypothesis_internal_use_settings"):
old = item._hypothesis_internal_use_settings
item._hypothesis_internal_use_settings = _settings(
old,
max_examples=forced_max,
)
'''
def _ensure_conftest():
"""确保 tests/conftest.py 中有 hypothesis max_examples 覆盖逻辑"""
conftest_path = PROJECT_ROOT / "tests" / "conftest.py"
marker = "HYPOTHESIS_MAX_EXAMPLES"
if conftest_path.exists():
content = conftest_path.read_text(encoding="utf-8")
if marker in content:
return False # 已有,不需要补丁
# 追加
with open(conftest_path, "a", encoding="utf-8") as f:
f.write("\n\n" + CONFTEST_PATCH)
return True
else:
conftest_path.write_text(CONFTEST_PATCH, encoding="utf-8")
return True
def _cleanup_conftest():
"""清理临时 conftest 补丁"""
conftest_path = PROJECT_ROOT / "tests" / "conftest.py"
if not conftest_path.exists():
return
content = conftest_path.read_text(encoding="utf-8")
if "由 _run_auth_pbt_full.py 自动生成" in content:
# 如果整个文件都是我们生成的,删除
if content.strip().startswith("# -*- coding: utf-8 -*-\n") and "HYPOTHESIS_MAX_EXAMPLES" in content:
lines = content.split("HYPOTHESIS_MAX_EXAMPLES")
# 简单判断:如果文件很短且主要是我们的内容
if len(content) < 500:
conftest_path.unlink()
return
# 否则只移除我们追加的部分
marker = "\n\n# -*- coding: utf-8 -*-\n\"\"\"\n临时 conftest"
if marker in content:
content = content[:content.index(marker)]
conftest_path.write_text(content, encoding="utf-8")
# ── 主流程 ────────────────────────────────────────────────────
def main():
parser = argparse.ArgumentParser(description="认证系统属性测试全量运行")
parser.add_argument(
"--max-examples", type=int, default=MAX_EXAMPLES,
help=f"每个属性测试的迭代次数(默认 {MAX_EXAMPLES}",
)
parser.add_argument(
"--only", type=str, default=None,
help="仅运行指定属性(逗号分隔,如 P1,P2,P14",
)
parser.add_argument(
"--skip", type=str, default=None,
help="跳过指定属性(逗号分隔,如 P3,P5",
)
parser.add_argument(
"--report-dir", type=str, default=None,
help="报告输出目录(默认 export/ 下)",
)
args = parser.parse_args()
# 筛选测试
tests_to_run = PROPERTY_TESTS[:]
if args.only:
only_ids = {x.strip().upper() for x in args.only.split(",")}
tests_to_run = [t for t in tests_to_run if t["id"] in only_ids]
if args.skip:
skip_ids = {x.strip().upper() for x in args.skip.split(",")}
tests_to_run = [t for t in tests_to_run if t["id"] not in skip_ids]
if not tests_to_run:
print("❌ 没有要运行的测试。")
sys.exit(1)
max_ex = args.max_examples
print(f"{''*58}")
print(f"║ 认证系统属性测试全量运行 ║")
print(f"║ 迭代次数: {max_ex:<5} 测试数: {len(tests_to_run):<3}")
print(f"║ 数据库: test_zqyy_app串行执行控制带宽")
print(f"{''*58}")
# 确保 conftest 补丁
patched = _ensure_conftest()
if patched:
print("📝 已注入 conftest.py hypothesis max_examples 覆盖")
total_start = _now_cst()
results = []
try:
for i, prop in enumerate(tests_to_run, 1):
print(f"\n📊 进度: {i}/{len(tests_to_run)}")
outcome = run_single_test(prop, max_examples=max_ex)
results.append(outcome)
# 测试间短暂休息,释放数据库连接
if i < len(tests_to_run):
time.sleep(1)
except KeyboardInterrupt:
print("\n\n⚠️ 用户中断,生成已完成部分的报告...")
except Exception as e:
print(f"\n\n❌ 运行异常: {e}")
finally:
total_end = _now_cst()
# 生成报告
if results:
report = generate_report(results, total_start, total_end)
# 确定报告路径
report_dir = Path(args.report_dir) if args.report_dir else PROJECT_ROOT / "export" / "reports"
report_dir.mkdir(parents=True, exist_ok=True)
timestamp = total_start.strftime("%Y%m%d_%H%M%S")
report_path = report_dir / f"auth_pbt_full_{timestamp}.md"
report_path.write_text(report, encoding="utf-8")
passed = sum(1 for r in results if r["passed"])
failed = len(results) - passed
print(f"\n{'='*60}")
print(f"📋 报告已生成: {report_path}")
print(f" 通过: {passed} 失败: {failed} 总计: {len(results)}")
print(f" 总耗时: {(total_end - total_start).total_seconds():.0f}s")
print(f"{'='*60}")
# 清理 conftest 补丁
if patched:
_cleanup_conftest()
print("🧹 已清理临时 conftest 补丁")
if __name__ == "__main__":
main()