mirror of
https://github.com/hpd840321/starRiverProperty.git
synced 2026-06-09 08:20:31 +08:00
docs+test: 发布包对拍计划、pytest 双端 API 对拍与 run_elevator_parity 脚本
- docs/elevator-api-parity: 计划/报告模板/示例 - tools/elevator_api_parity: 端点目录、fixtures、对拍 client/compare、报告生成 - scripts/run_elevator_parity: JDK8 构建 + 单测/对拍(无服务时跳过对拍用例) Made-with: Cursor Former-commit-id: 3d54a40e1a7ae0b1724261d4f18910a6f415f853
This commit is contained in:
@@ -12,6 +12,7 @@
|
||||
## 与仓库级文档的关系
|
||||
|
||||
- 全仓约定、走查、接口不变等说明见仓库根下 **`../../../docs/`**(相对本文件)。
|
||||
- 发布包与**历史 JAR 接口对拍**(计划/报告)见聚合工程目录 **`../../docs/elevator-api-parity/PLAN.md`**、脚本 `../../scripts/run_elevator_parity.sh`。
|
||||
- 本目录专注 **本 service 模块内** 类职责与业务流程梳理,不替代对外 API 合同文档。
|
||||
|
||||
## 分册导航
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
# 新旧版本发布包:接口同构验证计划
|
||||
|
||||
## 1. 目标
|
||||
|
||||
- **新构建物**:`cw-elevator-application-starter` 经 `mvn package` 产出的 `cw-elevator-application-2.0.0.jar`。
|
||||
- **历史基线**(约定路径,若不存在需单独拷贝):
|
||||
`.../cw-elevator-application-V1.0.0.20211103/cw-elevator-application-V1.0.0.20211103.jar`。
|
||||
- **成功标准**:在**相同运行配置与依赖环境**下,对同一批 HTTP 请求,两实例返回的 **HTTP 状态码、JSON 结构、业务 `code`(`CloudwalkResult`)** 一致;可约定忽略字段(时间戳、随机 `requestId` 等)在报告中注明。
|
||||
|
||||
## 2. 约束与先决条件
|
||||
|
||||
1. **双进程**:旧版默认端口 `18080`,新版 `18081`;均通过**同一套**外置或环境变量 `spring.config` 指向上游/库(与现网联调一致时才有可比性)。
|
||||
2. **无历史 JAR**:本仓库中若不存在上述 `*.jar`,仅完成构建与**框架/离线用例**;等效性验证需在拿到 JAR 后执行 `scripts/run_elevator_parity.sh`。
|
||||
3. **健康检查**:优先 `GET /actuator/health`;若基线为 Spring Boot 1.5 且 `management` 基路径不同,由脚本 `tools/elevator_api_parity/parity/health.py` 自动探测回退。
|
||||
|
||||
## 3. 子任务分解
|
||||
|
||||
| 序号 | 任务 | 产出 |
|
||||
|------|------|------|
|
||||
| T1 | Maven 发布包构建 | `target/cw-elevator-application-2.0.0.jar` |
|
||||
| T2 | 端点目录与用例体 | `tools/elevator_api_parity/api_catalog.json`、fixtures |
|
||||
| T3 | 双端 HTTP 对拍与比较 | `parity/*` + `pytest` |
|
||||
| T4 | 一键脚本与报告 | `scripts/run_elevator_parity.sh` → `report/parity-*.md` |
|
||||
| T5 | 在联调环境实跑、签字发布 | 人工/运维 |
|
||||
|
||||
## 4. 接口范围(与源码 Controller 一致)
|
||||
|
||||
- `/elevator/person/*`(含 `/add/visitor` 等)
|
||||
- `/elevator/passRule/*`
|
||||
- `/elevator/device/*`、`/elevator/restructure/*`
|
||||
- `/intelligent/acs/elevator/record/*`(`POST` 为主)
|
||||
- 设备通道 `/device/v2/*`(`39201` 等,按业务场景启用)
|
||||
|
||||
具体路径与请求体以 `api_catalog.json` 为准,可按现网场景追加 `fixtures/*.json`。
|
||||
|
||||
## 5. 非目标
|
||||
|
||||
- 不替代集成环境全链路压测、安全扫描。
|
||||
- 不强制在一台空库机子上与线上一致(需**相同数据与上游**才可比)。
|
||||
|
||||
## 6. 与本文档配套脚本
|
||||
|
||||
- 见 `maven-cw-elevator-application/tools/elevator_api_parity/README.md`。
|
||||
- 报告模板:`docs/elevator-api-parity/REPORT-TEMPLATE.md`(实跑时由 `generate_report.py` 自动填充为 `tools/elevator_api_parity/report/parity-*.md`)。
|
||||
@@ -0,0 +1,28 @@
|
||||
# 电梯应用 API 新旧版本对拍报告
|
||||
|
||||
- **报告生成时间**:(由脚本自动写入)
|
||||
- **新版权益端 Base URL**:
|
||||
- **历史基线端 Base URL**:
|
||||
|
||||
## 结论摘要
|
||||
|
||||
| 项 | 值 |
|
||||
|----|-----|
|
||||
| 总用例 | |
|
||||
| 通过 | |
|
||||
| 失败/跳过 | |
|
||||
| 可上线建议 | **通过/不通过/条件通过**(人工确认) |
|
||||
|
||||
## 逐条用例
|
||||
|
||||
| 名称 | 方法+路径 | HTTP(旧) | HTTP(新) | JSON 等值 | 备注 |
|
||||
|------|-----------|----------|----------|------------|------|
|
||||
| (表格由 `generate_report.py` 从 pytest 结果填充) | | | | | |
|
||||
|
||||
## 已忽略/归一化项
|
||||
|
||||
- `timestamp`、各层 `message` 文案差异、UUID 等(如策略允许可在 `parity_config.yaml` 配置)。
|
||||
|
||||
## 未启动实例时的说明
|
||||
|
||||
- 若出现「基线/新版无法连接健康检查」,对拍不执行,需在本机或联调机启动两 JAR 后重跑。
|
||||
@@ -0,0 +1,19 @@
|
||||
# 电梯应用 API 新旧对拍报告(示例)
|
||||
|
||||
- **时间**: 2026-04-25T12:00:00
|
||||
- **基线(旧) URL**: `http://127.0.0.1:18080`
|
||||
- **新构建 URL**: `http://127.0.0.1:18081`
|
||||
|
||||
## 用例
|
||||
|
||||
| 用例 | 方法+路径 | HTTP(旧) | HTTP(新) | 等值 | 说明 |
|
||||
| ---- | -------- | -------- | -------- | ---- | ---- |
|
||||
| person_add_visitor_min | `POST /elevator/person/add/visitor` | 200 | 200 | Y | 双端 `code` 一致(联调同库同配置时) |
|
||||
| person_detail | `POST /elevator/person/detail` | 200 | 200 | Y | 同上 |
|
||||
| passrule_page | `POST /elevator/passRule/page` | 200 | 200 | Y | 同上 |
|
||||
| record_page | `POST /intelligent/acs/elevator/record/page` | 200 | 200 | Y | 同上 |
|
||||
|
||||
## 汇总
|
||||
|
||||
- 通过: 4,不一致: 0。
|
||||
- **上线前请人工与联调/业务确认**(本表为结构示例,实跑见 `tools/elevator_api_parity/report/parity-*.md`)。
|
||||
@@ -0,0 +1,34 @@
|
||||
#!/usr/bin/env bash
|
||||
# 本脚本位于 maven-cw-elevator-application/scripts/:先打新包,再跑对拍用例与报告
|
||||
set -e
|
||||
REPO="$(cd "$(dirname "$0")/.." && pwd)"
|
||||
TOOL="${REPO}/tools/elevator_api_parity"
|
||||
JAR_NEW="${REPO}/cw-elevator-application-starter/target/cw-elevator-application-2.0.0.jar"
|
||||
JAR_LEGACY_DEFAULT="$(cd "$REPO/.." && pwd)/cw-elevator-application-V1.0.0.20211103/cw-elevator-application-V1.0.0.20211103.jar"
|
||||
JAR_LEGACY="${ELEVATOR_JAR_LEGACY:-$JAR_LEGACY_DEFAULT}"
|
||||
|
||||
if [[ -z "${JAVA_8:-}" ]]; then
|
||||
for d in /usr/lib/jvm/java-8-openjdk-amd64 /usr/lib/jvm/java-1.8.0-openjdk; do
|
||||
if [[ -x "$d/bin/java" ]]; then
|
||||
export JAVA_8="$d"
|
||||
break
|
||||
fi
|
||||
done
|
||||
fi
|
||||
: "${JAVA_8:=/usr/lib/jvm/java-8-openjdk-amd64}"
|
||||
export JAVA_HOME="$JAVA_8"
|
||||
export PATH="$JAVA_HOME/bin:$PATH"
|
||||
|
||||
cd "$REPO"
|
||||
echo "==> mvn package (skip tests)"
|
||||
mvn -q -DskipTests clean package
|
||||
echo "==> 新 JAR: $JAR_NEW [$(test -f "$JAR_NEW" && echo OK || echo 缺失)]"
|
||||
echo "==> 基线 JAR(路径): $JAR_LEGACY [$(test -f "$JAR_LEGACY" && echo 存在 || echo 不存在,可设ELEVATOR_JAR_LEGACY)]"
|
||||
echo " (对拍为 HTTP; 需另开终端分别 java -jar --server.port=18080/18081 与相同配置)"
|
||||
(cd "$TOOL" && pip install -q -r requirements.txt 2>/dev/null || pip3 install -q -r requirements.txt 2>/dev/null || true)
|
||||
echo "==> 单元(无联调): test_unit_compare"
|
||||
export PYTEST_DISABLE_PLUGIN_AUTOLOAD=1
|
||||
(cd "$TOOL" && python3 -m pytest tests/test_unit_compare.py -q --tb=short)
|
||||
echo "==> 对拍(无两实例时跳过 test_parity_endpoints): 全部"
|
||||
(cd "$TOOL" && python3 -m pytest tests/ -q --tb=line)
|
||||
echo "报告: $TOOL/report/ (对拍有执行且成功时由 pytest 会话写出 parity-*.md)"
|
||||
@@ -0,0 +1,4 @@
|
||||
__pycache__/
|
||||
*.pyc
|
||||
.pytest_cache/
|
||||
report/parity-*.md
|
||||
@@ -0,0 +1,44 @@
|
||||
# elevator_api_parity — 新旧 JAR 接口对拍
|
||||
|
||||
## 环境
|
||||
|
||||
- Python 3.8+
|
||||
- 安装:`pip install -r requirements.txt`(在**本目录**下执行)
|
||||
|
||||
## 环境说明
|
||||
|
||||
本机若安装过 `allure_pytest` 等全局 pytest 插件且与 Python 版本冲突,请执行:
|
||||
|
||||
`export PYTEST_DISABLE_PLUGIN_AUTOLOAD=1`(`run_elevator_parity.sh` 已自动设置)后再跑 `pytest`。
|
||||
|
||||
## 快速使用
|
||||
|
||||
1. 构建新 JAR(在 reactor 根目录、JDK8)
|
||||
`mvn -DskipTests clean package`
|
||||
得到:`../cw-elevator-application-starter/target/cw-elevator-application-2.0.0.jar`
|
||||
2. 将历史 JAR 放到 `cw-elevator-application-V1.0.0.20211103/cw-elevator-application-V1.0.0.20211103.jar` 或设 `ELEVATOR_JAR_LEGACY`。
|
||||
3. 准备**与现网相同**的 `application.yml`(或目录),如 `ELEVATOR_SPRING_CONFIG=file:/path/to/application-elevator.yml`。
|
||||
4. 从仓库**反编译**根或本目录执行:
|
||||
`../../scripts/run_elevator_parity.sh`(见该脚本内环境变量说明)
|
||||
|
||||
或手动启动两实例后:
|
||||
|
||||
```bash
|
||||
export ELEVATOR_BASE_OLD=http://127.0.0.1:18080
|
||||
export ELEVATOR_BASE_NEW=http://127.0.0.1:18081
|
||||
export ELEVATOR_HEADER_BUSINESSID=... # 与现网/联调一致
|
||||
# ...
|
||||
cd tools/elevator_api_parity
|
||||
python -m pytest tests/ -v --tb=short
|
||||
```
|
||||
|
||||
## 配置
|
||||
|
||||
- `api_catalog.json`:对拍端点、方法、fixture 文件名、说明。
|
||||
- `fixtures/*.json`:各接口请求体。
|
||||
- 归一化忽略键可扩展 `parity/compare.py` 中 `DEFAULT_STRIP_PATHS`(如 `data.rows` 内动态字段,谨慎)。
|
||||
|
||||
## 报告
|
||||
|
||||
- `report/parity-YYYYMMDD-HHMMSS.md`:实跑时由 `conftest` 的 session hook 与 `report/generate_report.py` 组合生成。
|
||||
- JUnit(可选):`--junitxml=report/junit.xml`
|
||||
@@ -0,0 +1,37 @@
|
||||
{
|
||||
"version": 1,
|
||||
"endpoints": [
|
||||
{
|
||||
"id": "person_add_visitor_min",
|
||||
"name": "访客派梯-最小体(多依赖业务失败,仅比对 HTTP+业务code)",
|
||||
"method": "POST",
|
||||
"path": "/elevator/person/add/visitor",
|
||||
"fixture": "person_add_visitor_min.json",
|
||||
"compare_mode": "code_only"
|
||||
},
|
||||
{
|
||||
"id": "person_detail",
|
||||
"name": "人员详情-最小分页体",
|
||||
"method": "POST",
|
||||
"path": "/elevator/person/detail",
|
||||
"fixture": "person_detail_min.json",
|
||||
"compare_mode": "code_only"
|
||||
},
|
||||
{
|
||||
"id": "passrule_page",
|
||||
"name": "通行规则-分页",
|
||||
"method": "POST",
|
||||
"path": "/elevator/passRule/page",
|
||||
"fixture": "passrule_page.json",
|
||||
"compare_mode": "code_only"
|
||||
},
|
||||
{
|
||||
"id": "record_page",
|
||||
"name": "通行记录-分页",
|
||||
"method": "POST",
|
||||
"path": "/intelligent/acs/elevator/record/page",
|
||||
"fixture": "record_page.json",
|
||||
"compare_mode": "code_only"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
import requests
|
||||
|
||||
from parity.client import can_reach_both, default_headers
|
||||
|
||||
_DIR = Path(__file__).resolve().parent
|
||||
|
||||
|
||||
def pytest_configure(config):
|
||||
config._parity_rows = [] # type: ignore[attr-defined]
|
||||
|
||||
|
||||
def pytest_addoption(parser):
|
||||
parser.addoption(
|
||||
"--base-old",
|
||||
default=os.environ.get("ELEVATOR_BASE_OLD", "http://127.0.0.1:18080"),
|
||||
)
|
||||
parser.addoption(
|
||||
"--base-new",
|
||||
default=os.environ.get("ELEVATOR_BASE_NEW", "http://127.0.0.1:18081"),
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture(scope="session")
|
||||
def base_old(request):
|
||||
return str(request.config.getoption("--base-old")).rstrip("/")
|
||||
|
||||
|
||||
@pytest.fixture(scope="session")
|
||||
def base_new(request):
|
||||
return str(request.config.getoption("--base-new")).rstrip("/")
|
||||
|
||||
|
||||
@pytest.fixture(scope="session")
|
||||
def session_http():
|
||||
s = requests.Session()
|
||||
s.headers.update(default_headers())
|
||||
yield s
|
||||
s.close()
|
||||
|
||||
|
||||
@pytest.fixture(scope="session")
|
||||
def two_instances_ready(base_old, base_new, session_http, request):
|
||||
ok, msg = can_reach_both(base_old, base_new, session_http)
|
||||
require = os.environ.get("ELEVATOR_PARITY_REQUIRE_LIVE", "")
|
||||
if not ok and not require:
|
||||
pytest.skip(f"双端健康检查不通过(跳过用例): {msg}")
|
||||
if not ok and require:
|
||||
pytest.fail(f"ELEVATOR_PARITY_REQUIRE_LIVE=1 且双端不可达: {msg}")
|
||||
return True
|
||||
|
||||
|
||||
def pytest_sessionfinish(session, exitstatus):
|
||||
rows = getattr(session.config, "_parity_rows", None)
|
||||
if not rows:
|
||||
return
|
||||
try:
|
||||
from report import generate_report
|
||||
|
||||
p = _DIR / "report" / generate_report.timestamped_name("parity")
|
||||
p.parent.mkdir(parents=True, exist_ok=True)
|
||||
generate_report.write_file(
|
||||
p,
|
||||
str(session.config.getoption("--base-old", default="")),
|
||||
str(session.config.getoption("--base-new", default="")),
|
||||
rows,
|
||||
)
|
||||
print(f"\n[parity] 报告: {p}")
|
||||
except Exception as e:
|
||||
print(f"\n[parity] 报告未生成: {e}")
|
||||
|
||||
|
||||
def load_catalog() -> dict:
|
||||
from parity import catalog_loader
|
||||
|
||||
return catalog_loader.load()
|
||||
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"pageNo": 1,
|
||||
"pageSize": 1
|
||||
}
|
||||
+7
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"visitorId": "",
|
||||
"personId": "",
|
||||
"begVisitorTime": 1,
|
||||
"endVisitorTime": 1,
|
||||
"floorIds": []
|
||||
}
|
||||
+8
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"currentPage": 1,
|
||||
"rowsOfPage": 1,
|
||||
"parentId": "",
|
||||
"zoneId": "",
|
||||
"ruleId": "",
|
||||
"personName": ""
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"currentPage": 1,
|
||||
"rowsOfPage": 1,
|
||||
"startTime": 1,
|
||||
"endTime": 2
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
"""Parity: HTTP client, health probe, JSON compare for elevator JARs."""
|
||||
@@ -0,0 +1,8 @@
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
_ROOT = Path(__file__).resolve().parent.parent
|
||||
|
||||
|
||||
def load() -> dict:
|
||||
return json.loads((_ROOT / "api_catalog.json").read_text(encoding="utf-8"))
|
||||
@@ -0,0 +1,135 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
from dataclasses import dataclass
|
||||
from typing import Any, Optional
|
||||
|
||||
import requests
|
||||
|
||||
from . import compare
|
||||
from .health import probe_healthy
|
||||
|
||||
|
||||
@dataclass
|
||||
class ParityResult:
|
||||
name: str
|
||||
method: str
|
||||
path: str
|
||||
old_status: int
|
||||
new_status: int
|
||||
old_text: str
|
||||
new_text: str
|
||||
match: bool
|
||||
message: str
|
||||
|
||||
|
||||
def default_headers() -> dict[str, str]:
|
||||
h: dict[str, str] = {
|
||||
"Content-Type": "application/json",
|
||||
}
|
||||
m = {
|
||||
"businessid": "ELEVATOR_HEADER_BUSINESSID",
|
||||
"loginid": "ELEVATOR_HEADER_LOGINID",
|
||||
"platformuserid": "ELEVATOR_HEADER_PLATFORMUSERID",
|
||||
"authorization": "ELEVATOR_HEADER_AUTHORIZATION",
|
||||
"applicationid": "ELEVATOR_HEADER_APPLICATIONID",
|
||||
}
|
||||
for hdr, evar in m.items():
|
||||
v = os.environ.get(evar, "").strip()
|
||||
if v:
|
||||
h[hdr] = v
|
||||
return h
|
||||
|
||||
|
||||
def call_both(
|
||||
name: str,
|
||||
method: str,
|
||||
path: str,
|
||||
body: Any,
|
||||
base_old: str,
|
||||
base_new: str,
|
||||
extra_headers: Optional[dict] = None,
|
||||
compare_mode: str = "deep",
|
||||
session: requests.Session | None = None,
|
||||
) -> ParityResult:
|
||||
s = session or requests.Session()
|
||||
h = {**default_headers(), **(extra_headers or {})}
|
||||
url_o = base_old.rstrip("/") + path
|
||||
url_n = base_new.rstrip("/") + path
|
||||
if body is None:
|
||||
data = None
|
||||
else:
|
||||
data = body if isinstance(body, str) else json.dumps(body, ensure_ascii=False)
|
||||
m = method.upper()
|
||||
if m == "GET":
|
||||
r_old = s.get(url_o, headers=h, timeout=60)
|
||||
r_new = s.get(url_n, headers=h, timeout=60)
|
||||
else:
|
||||
r_old = s.post(url_o, headers=h, data=data, timeout=120)
|
||||
r_new = s.post(url_n, headers=h, data=data, timeout=120)
|
||||
ot, nt = r_old.text, r_new.text
|
||||
st_o, st_n = r_old.status_code, r_new.status_code
|
||||
msg = ""
|
||||
|
||||
if compare_mode == "status_only":
|
||||
m_ok = st_o == st_n
|
||||
if not m_ok:
|
||||
msg = f"http {st_o} vs {st_n}"
|
||||
elif compare_mode == "code_only":
|
||||
msg = ""
|
||||
oj, nj = _safe_json(ot), _safe_json(nt)
|
||||
c1, c2 = compare.business_code(oj), compare.business_code(nj)
|
||||
m_ok = st_o == st_n
|
||||
if m_ok and c1 is not None and c2 is not None:
|
||||
m_ok = c1 == c2
|
||||
elif m_ok and c1 is None and c2 is None and st_o == st_n and 400 <= st_o < 600:
|
||||
m_ok = True
|
||||
msg = "http status only (no parseable JSON body)"
|
||||
elif m_ok and (c1 is None) != (c2 is None):
|
||||
m_ok = False
|
||||
if not m_ok and not msg:
|
||||
msg = compare.describe_diff(ot, nt, "code_only")
|
||||
elif m_ok and not msg:
|
||||
msg = ""
|
||||
else:
|
||||
m_ok = st_o == st_n
|
||||
oj, nj = _safe_json(ot), _safe_json(nt)
|
||||
if oj is not None and nj is not None and m_ok:
|
||||
m_ok = compare.normalize(oj) == compare.normalize(nj)
|
||||
elif m_ok and (ot or "") != (nt or ""):
|
||||
m_ok = (ot or "").strip() == (nt or "").strip()
|
||||
if not m_ok:
|
||||
msg = compare.describe_diff(ot, nt, "deep") or f"http {st_o}/{st_n} or body mismatch"
|
||||
|
||||
return ParityResult(
|
||||
name=name,
|
||||
method=m,
|
||||
path=path,
|
||||
old_status=st_o,
|
||||
new_status=st_n,
|
||||
old_text=ot,
|
||||
new_text=nt,
|
||||
match=m_ok,
|
||||
message=msg,
|
||||
)
|
||||
|
||||
|
||||
def _safe_json(text: str) -> Any:
|
||||
if not (text or "").strip():
|
||||
return None
|
||||
try:
|
||||
return compare.parse_json_loose(text)
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
def can_reach_both(
|
||||
base_old: str, base_new: str, s: requests.Session | None = None
|
||||
) -> tuple[bool, str]:
|
||||
s = s or requests.Session()
|
||||
p1, ok1, _ = probe_healthy(base_old, s)
|
||||
p2, ok2, _ = probe_healthy(base_new, s)
|
||||
if ok1 and ok2:
|
||||
return True, f"ok old health path~{p1} new probed"
|
||||
return False, f"health old ok={ok1} new ok={ok2} (paths like {p1})"
|
||||
@@ -0,0 +1,94 @@
|
||||
"""
|
||||
Compare two JSON response bodies. CloudwalkResult: compare top-level `code` when
|
||||
both parse as objects; also deep-compare with optional key stripping.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import copy
|
||||
import json
|
||||
from typing import Any, List, Set
|
||||
|
||||
# JSONPath-like: tuple of keys in order for nesting
|
||||
DEFAULT_STRIP_PATHS: Set[tuple] = {
|
||||
# ("data", "ts"),
|
||||
}
|
||||
|
||||
|
||||
def _strip(obj: Any, rules: Set[tuple], prefix: tuple) -> Any:
|
||||
if not isinstance(obj, (dict, list)):
|
||||
return obj
|
||||
if isinstance(obj, list):
|
||||
return [_strip(x, rules, prefix + (i,)) for i, x in enumerate(obj)]
|
||||
out = copy.deepcopy(obj)
|
||||
for k, v in list(obj.items()) if isinstance(obj, dict) else []:
|
||||
p = prefix + (k,)
|
||||
if p in rules:
|
||||
if k in out:
|
||||
del out[k]
|
||||
continue
|
||||
if isinstance(v, (dict, list)):
|
||||
out[k] = _strip(v, rules, p)
|
||||
return out
|
||||
|
||||
|
||||
def parse_json_loose(text: str) -> Any:
|
||||
if not (text or "").strip():
|
||||
return None
|
||||
return json.loads(text)
|
||||
|
||||
|
||||
def normalize(
|
||||
data: Any,
|
||||
strip_paths: Set[tuple] | None = None,
|
||||
) -> str:
|
||||
rules = strip_paths if strip_paths is not None else DEFAULT_STRIP_PATHS
|
||||
s = _strip(data, rules, tuple())
|
||||
return json.dumps(s, ensure_ascii=False, sort_keys=True, default=str)
|
||||
|
||||
|
||||
def business_code(data: Any) -> str | None:
|
||||
if isinstance(data, dict) and "code" in data:
|
||||
c = data.get("code")
|
||||
return str(c) if c is not None else None
|
||||
return None
|
||||
|
||||
|
||||
def same_shape(old_text: str, new_text: str) -> bool:
|
||||
try:
|
||||
a = parse_json_loose(old_text)
|
||||
b = parse_json_loose(new_text)
|
||||
except json.JSONDecodeError:
|
||||
return False
|
||||
if type(a) != type(b):
|
||||
return False
|
||||
if isinstance(a, dict) and isinstance(b, dict) and a.keys() == b.keys():
|
||||
return all(type(a[k]) is type(b[k]) for k in a)
|
||||
if isinstance(a, list) and isinstance(b, list):
|
||||
if len(a) == len(b) and len(a) == 0:
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def describe_diff(
|
||||
old_text: str, new_text: str, compare_norm: str = "deep"
|
||||
) -> str:
|
||||
try:
|
||||
o = parse_json_loose(old_text)
|
||||
n = parse_json_loose(new_text)
|
||||
except json.JSONDecodeError as e:
|
||||
return f"json parse: {e}"
|
||||
if compare_norm == "code_only":
|
||||
c1, c2 = business_code(o), business_code(n)
|
||||
if c1 == c2:
|
||||
return ""
|
||||
return f"business code: old={c1!r} new={c2!r}"
|
||||
no = normalize(o)
|
||||
nn = normalize(n)
|
||||
if no == nn:
|
||||
return ""
|
||||
if business_code(o) is not None and business_code(n) is not None and business_code(o) != business_code(
|
||||
n
|
||||
):
|
||||
return f"normalized JSON differs; first code: old={business_code(o)} new={business_code(n)}"
|
||||
return "normalized JSON differs (see full bodies in test log)"
|
||||
@@ -0,0 +1,29 @@
|
||||
"""Probes that work for both Spring Boot 1.5/2.x with common actuator settings."""
|
||||
|
||||
import requests
|
||||
|
||||
_CANDIDATES = (
|
||||
"/actuator/health",
|
||||
"/actuator/health/", # noqa: PIE
|
||||
"/health",
|
||||
"/application/health", # very old
|
||||
)
|
||||
|
||||
|
||||
def probe_healthy(base_url: str, session: requests.Session, timeout: float = 2.0) -> tuple[str, bool, int]:
|
||||
base = base_url.rstrip("/")
|
||||
last_status = 0
|
||||
for path in _CANDIDATES:
|
||||
try:
|
||||
r = session.get(base + path, timeout=timeout, allow_redirects=True)
|
||||
last_status = r.status_code
|
||||
if r.status_code == 200 and _body_up(r.text):
|
||||
return path, True, r.status_code
|
||||
except requests.RequestException:
|
||||
last_status = -1
|
||||
return _CANDIDATES[0], False, last_status
|
||||
|
||||
|
||||
def _body_up(text: str) -> bool:
|
||||
t = (text or "").lower()
|
||||
return "up" in t or '"status":"ok"' in t or '"status": "ok"' in t
|
||||
@@ -0,0 +1,8 @@
|
||||
[pytest]
|
||||
minversion = 6.0
|
||||
addopts = -q --strict-markers
|
||||
testpaths = tests
|
||||
markers =
|
||||
live: 需要两实例可访问
|
||||
unit: 纯逻辑单测
|
||||
pythonpath = .
|
||||
@@ -0,0 +1 @@
|
||||
"""Parity report generator."""
|
||||
@@ -0,0 +1,42 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
from typing import List
|
||||
|
||||
|
||||
def timestamped_name(prefix: str) -> str:
|
||||
return f"{prefix}-{datetime.now().strftime('%Y%m%d-%H%M%S')}.md"
|
||||
|
||||
|
||||
def write_file(
|
||||
out_path: Path,
|
||||
base_old: str,
|
||||
base_new: str,
|
||||
rows: List[dict],
|
||||
) -> None:
|
||||
lines = [
|
||||
f"# 电梯应用 API 新旧对拍报告\n",
|
||||
f"- **时间**: {datetime.now().isoformat()}\n",
|
||||
f"- **基线(旧) URL**: {base_old}\n",
|
||||
f"- **新构建 URL**: {base_new}\n",
|
||||
"\n## 用例\n\n",
|
||||
"| 用例 | 方法+路径 | HTTP(旧) | HTTP(新) | 等值 | 说明 |\n",
|
||||
"| ---- | -------- | -------- | -------- | ---- | ---- |\n",
|
||||
]
|
||||
ok, bad = 0, 0
|
||||
for r in rows:
|
||||
name = r.get("name", "")
|
||||
pth = f"{r.get('method', '')} {r.get('path', '')}"
|
||||
s1, s2 = r.get("old_status", ""), r.get("new_status", "")
|
||||
eq = "Y" if r.get("match") else "N"
|
||||
if r.get("match"):
|
||||
ok += 1
|
||||
else:
|
||||
bad += 1
|
||||
msg = (r.get("message", "") or "").replace("|", "\\|")[:200]
|
||||
lines.append(f"| {name} | `{pth}` | {s1} | {s2} | {eq} | {msg} |\n")
|
||||
lines.append(
|
||||
f"\n## 汇总\n- 通过: {ok},不一致: {bad}。\n- **上线前请人工与联调/业务确认**。\n"
|
||||
)
|
||||
out_path.write_text("".join(lines), encoding="utf-8")
|
||||
@@ -0,0 +1,2 @@
|
||||
requests>=2.28.0
|
||||
pytest>=7.4.0
|
||||
+58
@@ -0,0 +1,58 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
from parity.catalog_loader import load as load_catalog
|
||||
from parity.client import call_both
|
||||
|
||||
_DIR = Path(__file__).resolve().parent.parent
|
||||
_FIX = _DIR / "fixtures"
|
||||
|
||||
|
||||
@pytest.mark.usefixtures("two_instances_ready")
|
||||
@pytest.mark.live
|
||||
def test_parity_from_catalog(
|
||||
request,
|
||||
base_old,
|
||||
base_new,
|
||||
session_http,
|
||||
):
|
||||
"""按 api_catalog 双端对拍。compare_mode: deep | code_only | status_only"""
|
||||
cat = load_catalog()["endpoints"] # type: ignore[operator]
|
||||
for ep in cat:
|
||||
name = ep["id"]
|
||||
fixture = ep.get("fixture")
|
||||
body: dict
|
||||
if fixture:
|
||||
body = json.loads((_FIX / fixture).read_text(encoding="utf-8"))
|
||||
else:
|
||||
body = {}
|
||||
pr = call_both(
|
||||
name=ep.get("name", name),
|
||||
method=ep.get("method", "POST"),
|
||||
path=ep["path"],
|
||||
body=body,
|
||||
base_old=base_old,
|
||||
base_new=base_new,
|
||||
session=session_http,
|
||||
compare_mode=ep.get("compare_mode", "code_only"),
|
||||
)
|
||||
row = {
|
||||
"id": name,
|
||||
"name": ep.get("name", name),
|
||||
"method": pr.method,
|
||||
"path": pr.path,
|
||||
"old_status": pr.old_status,
|
||||
"new_status": pr.new_status,
|
||||
"match": pr.match,
|
||||
"message": pr.message,
|
||||
}
|
||||
request.config._parity_rows.append(row) # type: ignore
|
||||
# 在 code_only 下通常允许两边同一业务 code;若都非 200 也记录
|
||||
assert pr.match, (
|
||||
f"{name} mismatch: {pr.message}\n"
|
||||
f"old_http={pr.old_status} new_http={pr.new_status}\n"
|
||||
f"old_head={pr.old_text[:500]!r}\nnew_head={pr.new_text[:500]!r}"
|
||||
)
|
||||
@@ -0,0 +1,17 @@
|
||||
"""不依赖联调机:纯比较逻辑自测。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from parity import compare as C
|
||||
|
||||
|
||||
def test_normalize_equal():
|
||||
a = {"b": 2, "a": 1, "c": {"z": 1, "y": 2}}
|
||||
b = {"c": {"y": 2, "z": 1}, "a": 1, "b": 2}
|
||||
assert C.normalize(a) == C.normalize(b)
|
||||
|
||||
|
||||
def test_business_code_from_cloudwalk_result():
|
||||
j = '{"code":"0","data":{}}'
|
||||
p = C.parse_json_loose(j)
|
||||
assert C.business_code(p) == "0"
|
||||
Reference in New Issue
Block a user