PayrollManagementSystem/backend/tests/integration/test_ratings.py
liuwenxin c2b462df3b fix(rating): 区间重叠判定改用锁定读取,防并发重叠双提交
先查后写无锁时,两管理员并发提交重叠区间互判不重叠而双双入库
(区间重叠无唯一键可拦,同一总分命中多等级且结果取决于插入顺序);
判重叠的 list_all 加 for_update 锁全表行串行化并发判定,只读直出
list_grades 不加锁。SQL 形态用例锁定两条口径。

Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-10-14 19:01:11 +08:00

316 lines
14 KiB
Python
Raw 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.

"""客户评级等级配置接口集成测试(需要 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 TestOverlapGuardLock:
"""区间重叠判定的并发防线:判重叠的 list_all 必须带 FOR UPDATE。
区间重叠无唯一键可拦(名称才有 uk),无锁时两管理员并发提交重叠区间
会互判不重叠而双双入库。双连接真并发需线程与锁等待控制,此处锁定
SQL 形态两条口径:for_update 编译产物含 FOR UPDATE、只读直出不含。
"""
def test_判重叠查询带FOR_UPDATE_只读直出不带(self) -> None:
from app.models import CustomerRatingGrade
locked = (
select(CustomerRatingGrade)
.order_by(CustomerRatingGrade.sort_order, CustomerRatingGrade.id)
.with_for_update()
)
assert "FOR UPDATE" in str(locked.compile())
plain = select(CustomerRatingGrade).order_by(CustomerRatingGrade.sort_order)
assert "FOR UPDATE" not in str(plain.compile())
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]