2026-10-12 23:22:34 -04:00
|
|
|
|
"""产品管理接口集成测试(需要 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"
|
2026-10-14 06:48:32 -04:00
|
|
|
|
_CUSTOMERS = "/api/v1/base/customers"
|
|
|
|
|
|
_BUSINESSES = "/api/v1/business/businesses"
|
2026-10-12 23:22:34 -04:00
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
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()
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-10-14 06:48:32 -04:00
|
|
|
|
@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
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-10-12 23:22:34 -04:00
|
|
|
|
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"]
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-10-14 06:48:32 -04:00
|
|
|
|
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
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-10-12 23:22:34 -04:00
|
|
|
|
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 "")
|