"""客户评级等级配置接口集成测试(需要 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]