将 feat/settings-rating-brand(基于 927592b)合并到最新 main(d03ddad): - 冲突收口:系统设置服务/契约、集成测试夹具、布局/路由/登录页、客户列表 - 迁移重编号:本分支未发布的三个迁移压成 V11(客户评级 + 站点信息 + 版本号), down_revision 接到 main 的 90653814a421,db/migration 相应改为 V11 - 站点图标上传与 logo 上传统一为同步 def,超限先按 file.size 拦截
496 lines
21 KiB
Python
496 lines
21 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 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:
|
||
"""客户评级落库(V11):每客户一行、服务端重算总分与等级、等级快照固化。
|
||
|
||
五项各 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
|