"""客户评级等级配置接口集成测试(需要 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 func, select from app.core.constants import ACTION_CREATE, MODULE_RATING from app.core.db import get_session_factory from app.models import CustomerRating, CustomerRatingGrade, OperationLog from app.services.rating_service import resolve_grade pytestmark = pytest.mark.integration _GRADES = "/api/v1/business/rating-grades" _CUSTOMERS = "/api/v1/base/customers" # 种子基线(与 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 _create_customer(client: TestClient, headers: dict) -> dict: """建一名 t_ 前缀测试客户(评级落库的被引用方,conftest 兜底清理)。""" resp = client.post( _CUSTOMERS, json={ "company_name": f"t_评级客户_{uuid.uuid4().hex[:8]}", "owner_name": "t_老板", "customer_type": "personal", }, headers=headers, ) assert resp.status_code == 200, resp.text return resp.json()["data"] _RATING_KEYS = ( "score_ship_amount", "score_receipt_rate", "score_profit_rate", "score_contract_cooperation", "score_site_construction", ) def _rating_url(customer_id: int) -> str: return f"{_CUSTOMERS}/{customer_id}/rating" def _rating_payload(scores: list[str], remark: str | None = None) -> dict: body: dict = dict(zip(_RATING_KEYS, scores, strict=True)) if remark is not None: body["remark"] = remark return body 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] class TestResolveGrade: """判定纯函数边界:与前端打分卡同规则(sort_order 升序首个 min_score ≤ 总分)。 单边判定让边界分归 sort_order 靠前的等级:90→A、75→B、60→C、59.9→D; 等级表为空或总分低于全部下限时返回 None(落库等级为 NULL)。 """ @staticmethod def _seed_grades() -> list[CustomerRatingGrade]: return [ CustomerRatingGrade( grade_name=name, min_score=Decimal(min_score), max_score=Decimal(max_score), sort_order=order, ) for name, min_score, max_score, order in _SEED ] def test_边界分归高档(self) -> None: grades = self._seed_grades() assert resolve_grade(grades, Decimal("90")).grade_name == "A" assert resolve_grade(grades, Decimal("75")).grade_name == "B" assert resolve_grade(grades, Decimal("60")).grade_name == "C" def test_下限下方归最低档(self) -> None: assert resolve_grade(self._seed_grades(), Decimal("59.9")).grade_name == "D" def test_等级表为空_未命中(self) -> None: assert resolve_grade([], Decimal("80")) is None def test_低于全部下限_未命中(self) -> None: grades = [ CustomerRatingGrade( grade_name="A", min_score=Decimal("90"), max_score=Decimal("100"), sort_order=1, ) ] assert resolve_grade(grades, Decimal("50")) is None class TestCustomerRating: """客户评级落库(V7):每客户一行、服务端重算总分与等级、等级快照固化。 五项各 0-20 → 总分 0-100;等级按保存时点判定并写入 grade_name(无外键)。 未评级返回 data=null(正常态);分数越界 400 且审计留痕。 """ def test_未评级返回null(self, client: TestClient, normal: dict) -> None: customer = _create_customer(client, normal["headers"]) resp = client.get(_rating_url(customer["id"]), headers=normal["headers"]) assert resp.status_code == 200, resp.text assert resp.json()["data"] is None def test_保存评级_服务端重算总分等级并留审计(self, client: TestClient, normal: dict) -> None: headers = normal["headers"] customer = _create_customer(client, headers) put = client.put( _rating_url(customer["id"]), json=_rating_payload(["18", "20", "19", "17", "16"], remark="t_评级依据"), headers=headers, ) assert put.status_code == 200, put.text data = put.json()["data"] assert Decimal(data["total_score"]) == Decimal("90") assert data["grade_name"] == "A" assert data["remark"] == "t_评级依据" assert data["scorer_name"] == normal["username"] # 回读一致(列表评级列与弹窗回显同源) got = client.get(_rating_url(customer["id"]), headers=headers).json()["data"] assert Decimal(got["total_score"]) == Decimal("90") assert got["grade_name"] == "A" assert Decimal(got["score_ship_amount"]) == Decimal("18") logs = [log for log in _logs_of(normal["username"]) if log.module == MODULE_RATING] assert logs, "保存客户评级未留操作日志" assert logs[0].success == 1 assert customer["company_name"] in logs[0].summary assert "评级" in logs[0].summary def test_重复保存覆盖同一客户仅一行(self, client: TestClient, normal: dict) -> None: headers = normal["headers"] customer = _create_customer(client, headers) first = client.put( _rating_url(customer["id"]), json=_rating_payload(["0"] * 5), headers=headers ) assert first.status_code == 200, first.text assert first.json()["data"]["grade_name"] == "D" second = client.put( _rating_url(customer["id"]), json=_rating_payload(["20"] * 5), headers=headers ) assert second.status_code == 200, second.text assert Decimal(second.json()["data"]["total_score"]) == Decimal("100") assert second.json()["data"]["grade_name"] == "A" factory = get_session_factory() with factory() as session: count = session.execute( select(func.count()) .select_from(CustomerRating) .where(CustomerRating.customer_id == customer["id"]) ).scalar_one() assert count == 1, "同一客户出现多行评级(upsert 失效)" def test_单项分数越界_400(self, client: TestClient, normal: dict) -> None: headers = normal["headers"] customer = _create_customer(client, headers) for bad in (["21", "0", "0", "0", "0"], ["0", "-1", "0", "0", "0"]): resp = client.put( _rating_url(customer["id"]), json=_rating_payload(bad), headers=headers ) assert resp.status_code == 400, resp.text assert "0~20" in resp.json()["message"], resp.text def test_客户不存在_404(self, client: TestClient, normal: dict) -> None: headers = normal["headers"] assert client.get(_rating_url(999_999_999), headers=headers).status_code == 404 put = client.put(_rating_url(999_999_999), json=_rating_payload(["0"] * 5), headers=headers) assert put.status_code == 404, put.text def test_等级表清空后保存_等级为null(self, client: TestClient, normal: dict) -> None: """等级映射允许清空(快照设计前提):清空后评分照常保存,仅等级为 NULL。""" headers = normal["headers"] for name in ("A", "B", "C", "D"): assert ( client.delete( f"{_GRADES}/{_seed_id(client, headers, name)}", headers=headers ).status_code == 200 ) customer = _create_customer(client, headers) data = client.put( _rating_url(customer["id"]), json=_rating_payload(["20"] * 5), headers=headers ).json()["data"] assert Decimal(data["total_score"]) == Decimal("100") assert data["grade_name"] is None