PayrollManagementSystem/backend/tests/integration/test_products.py
liuwenxin 91fc6f6293 fix(product): 删除引用保护/单价小数位校验,IntegrityError 全局兜底转 400
- delete_product 补 count_refs 引用检查(业务明细+发货明细 RESTRICT
  外键)与并发窗口 IntegrityError→400 兜底(customer 先例同构),
  docstring 与两个 api 层过时注释同步更新
- 产品 schema 复用 schemas/business._check_amount:sale_price 走
  (PRICE_MAX, scale=4)、stock_quantity 走 AMOUNT_MAX——此前私写
  _parse_non_negative 不查小数位与容量,5 位小数被 Numeric(14,4)
  静默截断(舍入即算错钱);新增 6 个秒级 schema 单测锁定口径
- 全局注册 IntegrityError handler:漏补引用保护的删除类操作收敛为
  400 通用文案(e2e 撞见一个补一个的模式无法保证全覆盖),完整
  异常记日志;rating/customer 的「非 1062 按 500」注释同步更正

Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-10-14 18:48:32 +08:00

335 lines
13 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)。
守的是产品主数据的两条硬约束 —— product_category 是折算阈值的行级锚点,
脏类别会让核算按类别回溯参数取不到阈值;「名称+规格」组合唯一缺失会
放行重复产品,业务数据不知道该挂在哪一条上。数量/单价以字符串进出,
断言同时验证 str 直出(金额红线:不经 float)。
运行前置:``docker compose -f docker/docker-compose.dev.yml up -d``
数据卫生:产品名一律 ``t_`` 前缀,由 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_PRODUCT,
PRODUCT_CATEGORY_MUNICIPAL_WATER,
PRODUCT_CATEGORY_RAIL_TRANSIT,
)
from app.core.db import get_session_factory
from app.models import OperationLog
pytestmark = pytest.mark.integration
_PRODUCTS = "/api/v1/base/products"
_CUSTOMERS = "/api/v1/base/customers"
_BUSINESSES = "/api/v1/business/businesses"
def _pname() -> str:
return f"t_产品_{uuid.uuid4().hex[:8]}"
def _payload(name: str, **overrides: object) -> dict:
body: dict = {
"product_name": name,
"spec_model": "DN100",
"unit": "台",
"stock_quantity": "100",
"sale_price": "12.5",
"product_category": PRODUCT_CATEGORY_MUNICIPAL_WATER,
}
body.update(overrides)
return body
def _create(client: TestClient, headers: dict, **overrides: object) -> dict:
resp = client.post(_PRODUCTS, json=_payload(_pname(), **overrides), headers=headers)
assert resp.status_code == 200, resp.text
return resp.json()["data"]
def _logs_of(username: str) -> list[OperationLog]:
factory = get_session_factory()
with factory() as session:
return list(
session.execute(
select(OperationLog)
.where(OperationLog.username == username)
.order_by(OperationLog.id.desc())
).scalars()
)
@pytest.fixture
def make_business_for_product(client: TestClient):
"""造「客户 → 业务 → 明细挂指定产品」的最小引用链(删除保护场景)。
business_items.product_id 是 RESTRICT 外键,产品删除保护以此为引用源;
客户/业务均按 t_ 前缀与系统编号由 conftest 兜底清理。
"""
def _factory(headers: dict, product_id: int) -> dict:
customer = client.post(
_CUSTOMERS,
json={
"company_name": f"t_引用公司_{uuid.uuid4().hex[:8]}",
"owner_name": "张老板",
"customer_type": "personal",
},
headers=headers,
)
assert customer.status_code == 200, customer.text
business = client.post(
_BUSINESSES,
json={
"customer_id": customer.json()["data"]["id"],
"business_date": "2026-01-01",
"items": [
{
"product_id": product_id,
"product_name": f"t_引用产品_{uuid.uuid4().hex[:6]}",
"product_category": PRODUCT_CATEGORY_MUNICIPAL_WATER,
"quantity": "1.00",
"sale_price": "10.00",
}
],
},
headers=headers,
)
assert business.status_code == 200, business.text
return business.json()["data"]
return _factory
class TestAuth:
"""鉴权:登录后接口,裸访问一律 401。"""
def test_未登录访问产品列表_401(self, client: TestClient) -> None:
resp = client.get(_PRODUCTS)
assert resp.status_code == 401, resp.text
class TestCrudFlow:
"""正向链路:建 → 查 → 改(含换类别)→ 删。"""
def test_新增产品默认启用且数量单价字符串直出(self, client: TestClient, normal: dict) -> None:
data = _create(client, normal["headers"])
assert data["status"] == 1, "新产品应默认启用"
assert data["spec_model"] == "DN100"
# 金额红线:str 进 str 出,且数值与入参精确相等(Decimal 比较,不经 float)
assert isinstance(data["sale_price"], str)
assert Decimal(data["sale_price"]) == Decimal("12.5")
assert isinstance(data["stock_quantity"], str)
assert Decimal(data["stock_quantity"]) == Decimal("100")
def test_详情与列表均能查到新增产品(self, client: TestClient, normal: dict) -> None:
data = _create(client, normal["headers"])
detail = client.get(f"{_PRODUCTS}/{data['id']}", headers=normal["headers"])
assert detail.status_code == 200, detail.text
assert detail.json()["data"]["id"] == data["id"]
listed = client.get(
_PRODUCTS, params={"keyword": data["product_name"]}, headers=normal["headers"]
)
assert listed.status_code == 200, listed.text
assert [r["id"] for r in listed.json()["data"]["list"]] == [data["id"]]
def test_同名称不同规格创建成功(self, client: TestClient, normal: dict) -> None:
name = _pname()
first = _create(client, normal["headers"], product_name=name, spec_model="DN100")
second = client.post(
_PRODUCTS,
json=_payload(name, spec_model="DN200"),
headers=normal["headers"],
)
assert second.status_code == 200, second.text
assert second.json()["data"]["id"] != first["id"]
def test_删除产品成功(self, client: TestClient, normal: dict) -> None:
data = _create(client, normal["headers"])
resp = client.delete(f"{_PRODUCTS}/{data['id']}", headers=normal["headers"])
assert resp.status_code == 200, resp.text
detail = client.get(f"{_PRODUCTS}/{data['id']}", headers=normal["headers"])
assert detail.status_code == 404
assert "产品不存在" in detail.json()["message"]
class TestDeleteProtection:
"""删除保护:被业务/发货明细引用的产品禁删(customer 先例同构)。"""
def test_被业务明细引用_400且产品仍在(
self, client: TestClient, normal: dict, make_business_for_product
) -> None:
product = _create(client, normal["headers"])
business = make_business_for_product(normal["headers"], product["id"])
resp = client.delete(f"{_PRODUCTS}/{product['id']}", headers=normal["headers"])
assert resp.status_code == 400, resp.text
assert "引用" in resp.json()["message"]
# 产品仍在(引导改走停用而非物理删除)
still = client.get(f"{_PRODUCTS}/{product['id']}", headers=normal["headers"])
assert still.status_code == 200
# 业务明细行确实引用了该产品(护栏:确认保护不是空转)
detail = client.get(f"{_BUSINESSES}/{business['id']}", headers=normal["headers"]).json()[
"data"
]
assert detail["items"][0]["product_id"] == product["id"]
def test_被发货明细引用_400(
self, client: TestClient, normal: dict, make_business_for_product
) -> None:
product = _create(client, normal["headers"])
make_business_for_product(normal["headers"], product["id"])
# 在业务明细引用之外再挂一笔发货明细(同样引用 products.id)
businesses = client.get("/api/v1/business/businesses", headers=normal["headers"]).json()[
"data"
]
business_id = businesses["list"][0]["id"]
shipment = client.post(
"/api/v1/business/shipments",
json={
"business_id": business_id,
"ship_date": "2026-01-01",
"items": [
{
"product_id": product["id"],
"product_name": product["product_name"],
"quantity": "1.00",
"sale_price": "10.00",
}
],
},
headers=normal["headers"],
)
assert shipment.status_code == 200, shipment.text
# 此前业务明细引用已令删除被拒,此处再证发货引用同一防线
resp = client.delete(f"{_PRODUCTS}/{product['id']}", headers=normal["headers"])
assert resp.status_code == 400, resp.text
class TestValidationRules:
"""类别值域与组合唯一 —— 核算阈值锚点不脏、业务标识不重复。"""
def test_重名同规格_400(self, client: TestClient, normal: dict) -> None:
name = _pname()
_create(client, normal["headers"], product_name=name)
dup = client.post(
_PRODUCTS,
json=_payload(name, spec_model="DN100"),
headers=normal["headers"],
)
assert dup.status_code == 400, dup.text
assert "已存在同名称同规格的产品" in dup.json()["message"]
def test_规格缺省归一空串且重名同空规格仍拦截(self, client: TestClient, normal: dict) -> None:
"""组合唯一键对 NULL 不去重:缺省规格若归 NULL 会放行重名同规格数据。"""
name = _pname()
first = _create(client, normal["headers"], product_name=name, spec_model=None)
assert first["spec_model"] == ""
dup = client.post(
_PRODUCTS,
json=_payload(name, spec_model=None),
headers=normal["headers"],
)
assert dup.status_code == 400, dup.text
assert "已存在同名称同规格的产品" in dup.json()["message"]
def test_产品类别非法_400(self, client: TestClient, normal: dict) -> None:
resp = client.post(
_PRODUCTS,
json=_payload(_pname(), product_category="not_a_category"),
headers=normal["headers"],
)
assert resp.status_code == 400, resp.text
assert "产品类别不合法" in resp.json()["message"]
def test_销售单价为负数_400(self, client: TestClient, normal: dict) -> None:
resp = client.post(
_PRODUCTS,
json=_payload(_pname(), sale_price="-1"),
headers=normal["headers"],
)
assert resp.status_code == 400, resp.text
def test_销售单价非数字_400(self, client: TestClient, normal: dict) -> None:
resp = client.post(
_PRODUCTS,
json=_payload(_pname(), sale_price="abc"),
headers=normal["headers"],
)
assert resp.status_code == 400, resp.text
def test_编辑改类别为合法值成功(self, client: TestClient, normal: dict) -> None:
data = _create(client, normal["headers"])
updated = client.put(
f"{_PRODUCTS}/{data['id']}",
json=_payload(
data["product_name"],
product_category=PRODUCT_CATEGORY_RAIL_TRANSIT,
sale_price="15.8",
),
headers=normal["headers"],
)
assert updated.status_code == 200, updated.text
body = updated.json()["data"]
assert body["product_category"] == PRODUCT_CATEGORY_RAIL_TRANSIT
assert Decimal(body["sale_price"]) == Decimal("15.8")
class TestStatusSwitch:
"""启用/停用独立于编辑端点 —— 编辑表单不携带状态,防误触停用。"""
def test_停用再启用切换(self, client: TestClient, normal: dict) -> None:
headers = normal["headers"]
data = _create(client, headers)
off = client.put(f"{_PRODUCTS}/{data['id']}/status", json={"status": 0}, headers=headers)
assert off.status_code == 200, off.text
assert off.json()["message"] == "已停用"
assert off.json()["data"]["status"] == 0
on = client.put(f"{_PRODUCTS}/{data['id']}/status", json={"status": 1}, headers=headers)
assert on.status_code == 200, on.text
assert on.json()["message"] == "已启用"
assert on.json()["data"]["status"] == 1
class TestAudit:
"""写操作(含失败)留痕 —— 审计是验收项。"""
def test_新增产品成功留审计(self, client: TestClient, normal: dict) -> None:
data = _create(client, normal["headers"])
logs = [
log
for log in _logs_of(normal["username"])
if log.module == MODULE_PRODUCT and log.action == ACTION_CREATE
]
assert logs, "新增产品未留操作日志"
latest = logs[0]
assert latest.success == 1
assert data["product_name"] in latest.summary
def test_重名失败留审计(self, client: TestClient, normal: dict) -> None:
name = _pname()
_create(client, normal["headers"], product_name=name)
dup = client.post(
_PRODUCTS,
json=_payload(name, spec_model="DN100"),
headers=normal["headers"],
)
assert dup.status_code == 400, dup.text
failed = [
log
for log in _logs_of(normal["username"])
if log.module == MODULE_PRODUCT and log.success == 0
]
assert failed, "失败操作未留痕"
assert "已存在同名称同规格的产品" in (failed[0].error_message or "")