- 覆盖:编号格式与序号递增/状态机终态拦截/三处汇总冗余一致性/跨业务批次 400/已核算禁改禁删/毛利润金标准与防除零/asOf 回溯/评级重叠/审计双记录 - 缺陷修复①:change_status 留痕 old_status 取到赋值后属性(时间线失真 3→3) - 缺陷修复②:freight_amount 漏金额校验(负数/三位小数可落库污染运费合计) - conftest 扩展业务六表清理(编号前缀 + FK 逆序收口)与评级种子基线恢复 - 全量回归 603 passed(单元 381 / 集成 222) Co-Authored-By: Claude Code <noreply@anthropic.com>
295 lines
13 KiB
Python
295 lines
13 KiB
Python
"""客户评级等级配置接口集成测试(需要 MySQL 与 Redis)。
|
||
|
||
守的是评级**判定表**的两条硬约束 —— ① 区间互不重叠:重叠会让同一总分
|
||
命中多个等级(或两段都跳过),判定表失效,新增与编辑两路都必须校验;
|
||
② 分值域 0≤min≤max≤100:越界/倒挂的配置直接算错等级。边界相邻不算重叠
|
||
(前段 max=74、后段 min=75 相邻)是种子铺满 0-100 的铺法,要有正例锁住;
|
||
「已存在」的名称唯一在先查与 1062 两路都要收敛成 400。
|
||
|
||
评级用例会**删改种子**(删除 C、编辑 D 的区间构造相邻/重叠场景),conftest
|
||
兜底在每个用例后把判定表恢复为种子基线(全删全插,判定表无下游引用),
|
||
因此种子断言用例(列表全量返回)始终在纯种子上运行。
|
||
|
||
运行前置:``docker compose -f docker/docker-compose.dev.yml up -d``
|
||
|
||
数据卫生:测试等级名一律 ``t_`` 前缀;种子(A/B/C/D)的删改由 conftest
|
||
恢复,删除语义的用例自验 DELETE 端点。
|
||
"""
|
||
|
||
import uuid
|
||
from decimal import Decimal
|
||
|
||
import pytest
|
||
from fastapi.testclient import TestClient
|
||
from sqlalchemy import select
|
||
|
||
from app.core.constants import ACTION_CREATE, MODULE_RATING
|
||
from app.core.db import get_session_factory
|
||
from app.models import OperationLog
|
||
|
||
pytestmark = pytest.mark.integration
|
||
|
||
_GRADES = "/api/v1/business/rating-grades"
|
||
|
||
# 种子基线(与 V5 迁移一致):分数字符串经 DB round-trip 后一位小数(Numeric(5,1))
|
||
_SEED = [
|
||
("A", "90.0", "100.0", 1),
|
||
("B", "75.0", "89.0", 2),
|
||
("C", "60.0", "74.0", 3),
|
||
("D", "0.0", "59.0", 4),
|
||
]
|
||
|
||
|
||
def _tname() -> str:
|
||
return f"t_等级_{uuid.uuid4().hex[:6]}"
|
||
|
||
|
||
def _gpayload(grade_name: str, min_score: str, max_score: str, **overrides: object) -> dict:
|
||
body: dict = {
|
||
"grade_name": grade_name,
|
||
"min_score": min_score,
|
||
"max_score": max_score,
|
||
"sort_order": 99,
|
||
"remark": "t_评级测试",
|
||
}
|
||
body.update(overrides)
|
||
return body
|
||
|
||
|
||
def _seed_id(client: TestClient, headers: dict, grade_name: str) -> int:
|
||
"""从判定表列表取种子等级 id(种子由迁移初始化,主键不可假造)。"""
|
||
rows = client.get(_GRADES, headers=headers).json()["data"]
|
||
match = next(row for row in rows if row["grade_name"] == grade_name)
|
||
return match["id"]
|
||
|
||
|
||
def _drop_seed(client: TestClient, headers: dict, grade_name: str) -> None:
|
||
"""删掉种子等级,为测试等级清出分值空隙(种子由 conftest 兜底恢复)。
|
||
|
||
种子铺满 0-100 —— 凡要创建/编辑测试等级的用例,必须先清出与既有等级
|
||
完全分离的空隙,否则重叠约束(设计如此)一律 400。
|
||
"""
|
||
resp = client.delete(f"{_GRADES}/{_seed_id(client, headers, grade_name)}", headers=headers)
|
||
assert resp.status_code == 200, resp.text
|
||
|
||
|
||
def _names(client: TestClient, headers: dict) -> list[str]:
|
||
return [row["grade_name"] for row in client.get(_GRADES, headers=headers).json()["data"]]
|
||
|
||
|
||
def _logs_of(username: str) -> list[OperationLog]:
|
||
"""取某操作人的审计行(按 id 倒序,最新在前)。"""
|
||
factory = get_session_factory()
|
||
with factory() as session:
|
||
return list(
|
||
session.execute(
|
||
select(OperationLog)
|
||
.where(OperationLog.username == username)
|
||
.order_by(OperationLog.id.desc())
|
||
).scalars()
|
||
)
|
||
|
||
|
||
class TestAuth:
|
||
"""鉴权:评级配置属业务操作不要求管理员,但仍是登录后接口。"""
|
||
|
||
def test_未登录访问评级等级列表_401(self, client: TestClient) -> None:
|
||
resp = client.get(_GRADES)
|
||
assert resp.status_code == 401, resp.text
|
||
|
||
|
||
class TestList:
|
||
"""列表直出种子基线:判定表是前端评级计算器的唯一数据源。"""
|
||
|
||
def test_种子四等级全量返回_按sort_order升序(self, client: TestClient, normal: dict) -> None:
|
||
resp = client.get(_GRADES, headers=normal["headers"])
|
||
assert resp.status_code == 200, resp.text
|
||
rows = resp.json()["data"]
|
||
assert [(r["grade_name"], r["sort_order"]) for r in rows] == [
|
||
(name, order) for name, _, _, order in _SEED
|
||
], rows
|
||
# 分值红线:str 出参且与种子精确相等(Decimal 比较,不经 float)
|
||
for row, (_, min_score, max_score, _) in zip(rows, _SEED, strict=True):
|
||
assert Decimal(row["min_score"]) == Decimal(min_score)
|
||
assert Decimal(row["max_score"]) == Decimal(max_score)
|
||
|
||
|
||
class TestCreate:
|
||
"""新增:区间合法(0≤min≤max≤100)、互不重叠、名称唯一 —— 三条硬约束
|
||
任一缺失都会算错等级。"""
|
||
|
||
def test_删除种子后新增相邻区间_成功且判定表更新(
|
||
self, client: TestClient, normal: dict
|
||
) -> None:
|
||
"""种子铺满 0-100,任何新区间都与某条重叠 —— 先删 C(60-74),
|
||
新增 60-74 与 D(0-59)/B(75-89) **边界相邻不算重叠**(正例)。"""
|
||
headers = normal["headers"]
|
||
client.delete(f"{_GRADES}/{_seed_id(client, headers, 'C')}", headers=headers)
|
||
resp = client.post(
|
||
_GRADES, json=_gpayload("t_E", "60", "74", sort_order=5), headers=headers
|
||
)
|
||
assert resp.status_code == 200, resp.text
|
||
body = resp.json()["data"]
|
||
assert Decimal(body["min_score"]) == Decimal("60")
|
||
assert Decimal(body["max_score"]) == Decimal("74")
|
||
assert body["sort_order"] == 5
|
||
|
||
rows = client.get(_GRADES, headers=headers).json()["data"]
|
||
assert _names(client, headers) == ["A", "B", "D", "t_E"], rows
|
||
|
||
def test_区间倒挂_400(self, client: TestClient, normal: dict) -> None:
|
||
resp = client.post(_GRADES, json=_gpayload(_tname(), "80", "10"), headers=normal["headers"])
|
||
assert resp.status_code == 400, resp.text
|
||
assert "分值区间不合法" in resp.json()["message"], resp.text
|
||
|
||
def test_分值超上限_400(self, client: TestClient, normal: dict) -> None:
|
||
resp = client.post(
|
||
_GRADES, json=_gpayload(_tname(), "95", "150"), headers=normal["headers"]
|
||
)
|
||
assert resp.status_code == 400, resp.text
|
||
assert "分值区间不合法" in resp.json()["message"], resp.text
|
||
|
||
def test_分值负下限_400(self, client: TestClient, normal: dict) -> None:
|
||
resp = client.post(_GRADES, json=_gpayload(_tname(), "-5", "10"), headers=normal["headers"])
|
||
assert resp.status_code == 400, resp.text
|
||
assert "分值区间不合法" in resp.json()["message"], resp.text
|
||
|
||
def test_等级名称已存在_400(self, client: TestClient, normal: dict) -> None:
|
||
"""先删 D 清出空隙:重叠校验在名称唯一校验之前,不清空隙会先撞
|
||
「与等级 D 重叠」而走不到名称校验。"""
|
||
headers = normal["headers"]
|
||
_drop_seed(client, headers, "D")
|
||
resp = client.post(_GRADES, json=_gpayload("A", "10", "20"), headers=headers)
|
||
assert resp.status_code == 400, resp.text
|
||
assert "该等级名称已存在" in resp.json()["message"], resp.text
|
||
|
||
def test_新增与种子重叠_400点名冲突等级(self, client: TestClient, normal: dict) -> None:
|
||
"""85-95 与种子 A(90-100) 闭区间相交 → 400 并点名 A,前端据此定位行。"""
|
||
resp = client.post(_GRADES, json=_gpayload(_tname(), "85", "95"), headers=normal["headers"])
|
||
assert resp.status_code == 400, resp.text
|
||
assert "分值区间与等级 A 重叠" in resp.json()["message"], resp.text
|
||
# 失败不落库:判定表仍是 4 条种子
|
||
assert _names(client, normal["headers"]) == ["A", "B", "C", "D"]
|
||
|
||
|
||
class TestEdit:
|
||
"""编辑以修改后的区间对全部既有等级(排除自身)两两相交 —— 编辑路与
|
||
新增路共用重叠约束,两处都要拦。"""
|
||
|
||
def test_编辑与其他等级重叠_400点名冲突等级(self, client: TestClient, normal: dict) -> None:
|
||
"""把 B 编辑为 60-65:与 C(60-74) 相交 → 点名 C;与 D(0-59)/A 均分离。"""
|
||
headers = normal["headers"]
|
||
b_id = _seed_id(client, headers, "B")
|
||
resp = client.put(
|
||
f"{_GRADES}/{b_id}",
|
||
json=_gpayload("B", "60", "65", sort_order=2),
|
||
headers=headers,
|
||
)
|
||
assert resp.status_code == 400, resp.text
|
||
assert "分值区间与等级 C 重叠" in resp.json()["message"], resp.text
|
||
# 失败不落库:种子保持原样
|
||
assert _names(client, headers) == ["A", "B", "C", "D"]
|
||
|
||
def test_编辑相邻区间_成功且判定表更新(self, client: TestClient, normal: dict) -> None:
|
||
"""删除 C 后把 D 编辑为 0-74:与 B(75-89) **边界相邻不算重叠**
|
||
(74 与 75 相邻),这是种子铺法的正例。"""
|
||
headers = normal["headers"]
|
||
client.delete(f"{_GRADES}/{_seed_id(client, headers, 'C')}", headers=headers)
|
||
d_id = _seed_id(client, headers, "D")
|
||
resp = client.put(
|
||
f"{_GRADES}/{d_id}",
|
||
json=_gpayload("D", "0", "74", sort_order=4),
|
||
headers=headers,
|
||
)
|
||
assert resp.status_code == 200, resp.text
|
||
|
||
rows = client.get(_GRADES, headers=headers).json()["data"]
|
||
assert [(r["grade_name"], r["sort_order"]) for r in rows] == [("A", 1), ("B", 2), ("D", 4)]
|
||
assert Decimal(rows[2]["max_score"]) == Decimal("74"), rows[2]
|
||
|
||
def test_编辑回显改名与分数(self, client: TestClient, normal: dict) -> None:
|
||
headers = normal["headers"]
|
||
_drop_seed(client, headers, "D") # 清出 0-59 空隙供测试等级落脚
|
||
grade_name = _tname()
|
||
created = client.post(
|
||
_GRADES, json=_gpayload(grade_name, "10", "20", sort_order=50), headers=headers
|
||
)
|
||
assert created.status_code == 200, created.text
|
||
grade_id = created.json()["data"]["id"]
|
||
|
||
new_name = _tname()
|
||
updated = client.put(
|
||
f"{_GRADES}/{grade_id}",
|
||
json=_gpayload(new_name, "15", "25", sort_order=60, remark="t_改"),
|
||
headers=headers,
|
||
)
|
||
assert updated.status_code == 200, updated.text
|
||
body = updated.json()["data"]
|
||
assert body["grade_name"] == new_name
|
||
assert Decimal(body["min_score"]) == Decimal("15")
|
||
assert Decimal(body["max_score"]) == Decimal("25")
|
||
assert body["sort_order"] == 60
|
||
|
||
rows = client.get(_GRADES, headers=headers).json()["data"]
|
||
match = next(r for r in rows if r["id"] == grade_id)
|
||
assert match["grade_name"] == new_name
|
||
assert Decimal(match["min_score"]) == Decimal("15")
|
||
|
||
|
||
class TestDelete:
|
||
"""删除允许清空(评级是纯计算器、评分记录不落库)—— 删除后判定表
|
||
立即反映,二次删除 404。"""
|
||
|
||
def test_删除等级后判定表更新_二次删除404(self, client: TestClient, normal: dict) -> None:
|
||
headers = normal["headers"]
|
||
_drop_seed(client, headers, "D") # 清出 0-59 空隙供测试等级落脚
|
||
grade_name = _tname()
|
||
created = client.post(
|
||
_GRADES, json=_gpayload(grade_name, "30.5", "40.5", sort_order=50), headers=headers
|
||
)
|
||
assert created.status_code == 200, created.text
|
||
grade_id = created.json()["data"]["id"]
|
||
assert grade_name in _names(client, headers)
|
||
|
||
assert client.delete(f"{_GRADES}/{grade_id}", headers=headers).status_code == 200
|
||
assert grade_name not in _names(client, headers)
|
||
|
||
again = client.delete(f"{_GRADES}/{grade_id}", headers=headers)
|
||
assert again.status_code == 404, again.text
|
||
assert "评级等级不存在" in again.json()["message"], again.text
|
||
|
||
|
||
class TestAudit:
|
||
"""写操作(含校验失败的)都在 operation_logs 留痕 —— 审计是验收项。"""
|
||
|
||
def test_新增等级成功留审计(self, client: TestClient, normal: dict) -> None:
|
||
headers = normal["headers"]
|
||
_drop_seed(client, headers, "D") # 清出 0-59 空隙供测试等级落脚
|
||
grade_name = _tname()
|
||
resp = client.post(
|
||
_GRADES,
|
||
json=_gpayload(grade_name, "30.5", "40.5", sort_order=50),
|
||
headers=headers,
|
||
)
|
||
assert resp.status_code == 200, resp.text
|
||
logs = [
|
||
log
|
||
for log in _logs_of(normal["username"])
|
||
if log.module == MODULE_RATING and log.action == ACTION_CREATE
|
||
]
|
||
assert logs, "新增评级等级未留操作日志"
|
||
latest = logs[0]
|
||
assert latest.success == 1
|
||
assert grade_name in latest.summary
|
||
|
||
def test_重叠失败留审计(self, client: TestClient, normal: dict) -> None:
|
||
resp = client.post(_GRADES, json=_gpayload(_tname(), "85", "95"), headers=normal["headers"])
|
||
assert resp.status_code == 400, resp.text
|
||
failed = [
|
||
log
|
||
for log in _logs_of(normal["username"])
|
||
if log.module == MODULE_RATING and log.success == 0
|
||
]
|
||
assert failed, "失败操作未留痕"
|
||
assert "分值区间与等级 A 重叠" in (failed[0].error_message or ""), failed[0]
|