Files
dealerhub/backend/tests/test_storefront.py
T

526 lines
20 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.
"""批次 D1 · B2B 订货商城测试。
覆盖:商品白名单可见性、商城价复用取价引擎、多单位下单、额度拦截、
SalesOrder 草稿流转、驳回、会话 token 安全、内部授权管理、billing 功能开关。
"""
import pytest
from datetime import date
from decimal import Decimal
from model_bakery import baker
from rest_framework.test import APIClient
from rest_framework_simplejwt.tokens import RefreshToken
from apps.catalog.models import Product, Unit, UnitConversion
from apps.inventory.models import Warehouse
from apps.partner.models import Customer, CustomerProductPrice
from apps.storefront.models import (
CustomerProductAuth, StorefrontAccount, StorefrontOrder,
issue_session_token, verify_session_token,
)
from apps.storefront import services as sf_services
@pytest.fixture
def client(db):
"""覆盖 pytest-django 默认的 Django 测试客户端。
默认 client 不认 `format="json"`,会把 JSON 结构按表单编码(导致 lines 变成
字符串列表)。商城用例都用 DRF 的 APIClient。
"""
return APIClient()
@pytest.fixture
def pro_plan(db, tenant):
"""默认给测试租户专业版:商城是专业版功能(billing 功能开关)。"""
from apps.billing.models import Plan, Subscription, seed_plans
seed_plans()
pro = Plan.objects.get(code="pro")
Subscription.objects.filter(tenant=tenant).delete()
Subscription.objects.create(
tenant=tenant, plan=pro, status="active",
period_start=date.today(), period_end=date.today(),
)
return pro
@pytest.fixture
def auth_client(db, user, tenant):
c = APIClient()
refresh = RefreshToken.for_user(user)
c.credentials(
HTTP_AUTHORIZATION=f"Bearer {refresh.access_token}",
HTTP_X_TENANT_ID=tenant.code,
)
return c
@pytest.fixture
def base_unit(db, tenant):
return baker.make(Unit, tenant=tenant, code="bottle", name="瓶", is_base=True)
@pytest.fixture
def box_unit(db, tenant):
return baker.make(Unit, tenant=tenant, code="box", name="箱", is_base=False)
@pytest.fixture
def customer(db, tenant):
return baker.make(Customer, tenant=tenant, code="C001", name="张三便利店",
phone="13900000001", credit_limit=Decimal("10000"))
@pytest.fixture
def warehouse(db, tenant):
return baker.make(Warehouse, tenant=tenant, code="WH01", name="主仓",
is_default=True, is_active=True)
@pytest.fixture
def products(db, tenant, base_unit, box_unit):
out = {}
for i, (code, name, price) in enumerate([
("P001", "可乐", "3.00"), ("P002", "雪碧", "3.00"),
("P003", "鲜牛奶", "15.00"),
], start=1):
p = baker.make(Product, tenant=tenant, code=code, name=name,
sale_price=Decimal(price), base_unit=base_unit,
status="active")
UnitConversion.objects.create(tenant=tenant, product=p, unit=box_unit,
rate=Decimal("24"))
out[code] = p
# 目录外商品(不授权)
out["SECRET"] = baker.make(Product, tenant=tenant, code="P999", name="未授权商品",
sale_price=Decimal("99"), base_unit=base_unit,
status="active")
return out
@pytest.fixture
def account(db, tenant, customer):
a = baker.make(StorefrontAccount, tenant=tenant, customer=customer,
phone="13900000001", display_name="张三便利店")
a.set_password("store12345")
a.save(update_fields=["password_hash"])
return a
@pytest.fixture
def granted(db, tenant, customer, products):
"""授权 P001/P002 给客户(P003、P999 不授权)。"""
for code in ("P001", "P002"):
CustomerProductAuth.objects.create(
tenant=tenant, customer=customer, product=products[code], is_active=True
)
return True
def _login(client, phone="13900000001", password="store12345", tenant="test"):
return client.post("/api/v1/storefront/login/",
{"phone": phone, "password": password, "tenant_code": tenant},
format="json")
# ---------- 会话 token ----------
def test_session_token_roundtrip(db, account):
token = issue_session_token(account)
got = verify_session_token(token)
assert got is not None
assert got.id == account.id
def test_session_token_rejects_tampering(db, account):
token = issue_session_token(account)
bad = token[:-1] + ("0" if token[-1] != "0" else "1")
assert verify_session_token(bad) is None
def test_session_token_rejects_expired(db, account, monkeypatch):
token = issue_session_token(account, ttl_hours=-1)
assert verify_session_token(token) is None
def test_password_hashing(db, account):
assert account.check_password("store12345") is True
assert account.check_password("wrong") is False
assert "store12345" not in account.password_hash
# ---------- 登录 ----------
def test_login_success(db, client, pro_plan, account):
resp = _login(client)
assert resp.status_code == 200, resp.content
body = resp.json()
assert body["token"]
assert body["customer"]["code"] == "C001"
def test_login_wrong_password(db, client, pro_plan, account):
resp = _login(client, password="nope")
assert resp.status_code == 401
assert resp.json()["code"] == "invalid_credentials"
def test_login_unknown_phone(db, client, pro_plan, account):
resp = _login(client, phone="13000000000")
assert resp.status_code == 401
# ---------- 可见性(白名单) ----------
def test_catalog_hides_unauthorized_products(db, client, pro_plan, tenant, account, products, granted):
token = _login(client).json()["token"]
resp = client.get("/api/v1/storefront/catalog/", HTTP_X_TENANT_ID=tenant.code,
HTTP_AUTHORIZATION=f"Storefront {token}")
assert resp.status_code == 200, resp.content
codes = {r["code"] for r in resp.json()["results"]}
assert codes == {"P001", "P002"}
assert "P999" not in codes # 未授权商品不可见
assert "P003" not in codes
def test_catalog_requires_login(db, client, pro_plan, tenant, granted):
resp = client.get("/api/v1/storefront/catalog/", HTTP_X_TENANT_ID=tenant.code)
assert resp.status_code == 401
def test_catalog_search(db, client, pro_plan, tenant, account, granted):
token = _login(client).json()["token"]
resp = client.get("/api/v1/storefront/catalog/?search=可乐", HTTP_X_TENANT_ID=tenant.code,
HTTP_AUTHORIZATION=f"Storefront {token}")
codes = [r["code"] for r in resp.json()["results"]]
assert codes == ["P001"]
# ---------- 商城价(复用取价引擎) ----------
def test_catalog_price_uses_customer_specific_price(db, client, pro_plan, tenant, account,
customer, products, granted):
CustomerProductPrice.objects.create(
tenant=tenant, customer=customer, product=products["P001"], price=Decimal("2.50")
)
token = _login(client).json()["token"]
resp = client.get("/api/v1/storefront/catalog/", HTTP_X_TENANT_ID=tenant.code,
HTTP_AUTHORIZATION=f"Storefront {token}")
row = next(r for r in resp.json()["results"] if r["code"] == "P001")
assert Decimal(row["price"]) == Decimal("2.50")
assert row["price_source"] == "customer"
def test_catalog_price_falls_back_to_default(db, client, pro_plan, tenant, account, products, granted):
token = _login(client).json()["token"]
resp = client.get("/api/v1/storefront/catalog/", HTTP_X_TENANT_ID=tenant.code,
HTTP_AUTHORIZATION=f"Storefront {token}")
row = next(r for r in resp.json()["results"] if r["code"] == "P002")
assert Decimal(row["price"]) == Decimal("3.00")
assert row["price_source"] == "default"
def test_catalog_lists_units_with_prices(db, client, pro_plan, tenant, account, products, granted):
token = _login(client).json()["token"]
resp = client.get("/api/v1/storefront/catalog/", HTTP_X_TENANT_ID=tenant.code,
HTTP_AUTHORIZATION=f"Storefront {token}")
row = next(r for r in resp.json()["results"] if r["code"] == "P001")
units = {u["unit_name"]: u for u in row["units"]}
assert Decimal(units["箱"]["rate"]) == Decimal("24")
assert Decimal(units["瓶"]["price"]) == Decimal("3.00")
assert Decimal(units["箱"]["price"]) == Decimal("72.00")
# ---------- 下单 ----------
def test_submit_order_creates_storefront_order(db, client, pro_plan, tenant, account,
products, granted):
token = _login(client).json()["token"]
resp = client.post("/api/v1/storefront/orders/", {
"lines": [{"product_id": products["P001"].id, "quantity": 10}],
"remark": "明天送",
}, format="json", HTTP_X_TENANT_ID=tenant.code,
HTTP_AUTHORIZATION=f"Storefront {token}")
assert resp.status_code == 201, resp.content
body = resp.json()
assert body["status"] == "submitted"
assert Decimal(body["total_amount"]) == Decimal("30.00")
order = StorefrontOrder.objects.get(pk=body["id"])
assert order.lines.count() == 1
assert order.lines.first().quantity == Decimal("10")
def test_submit_order_with_box_unit(db, client, pro_plan, tenant, account, products, granted):
"""按箱下单:数量折基本单位,单价按箱价。"""
box = UnitConversion.objects.get(product=products["P001"]).unit
token = _login(client).json()["token"]
resp = client.post("/api/v1/storefront/orders/", {
"lines": [{"product_id": products["P001"].id, "quantity": 2,
"source_unit": box.id}],
}, format="json", HTTP_X_TENANT_ID=tenant.code,
HTTP_AUTHORIZATION=f"Storefront {token}")
assert resp.status_code == 201, resp.content
order = StorefrontOrder.objects.get(pk=resp.json()["id"])
line = order.lines.first()
assert line.quantity == Decimal("48") # 2 × 24
assert line.source_quantity == Decimal("2")
assert Decimal(line.unit_price) == Decimal("72") # 3 × 24
assert Decimal(order.total_amount) == Decimal("144")
def test_submit_order_rejects_unauthorized_product(db, client, pro_plan, tenant, account,
products, granted):
token = _login(client).json()["token"]
resp = client.post("/api/v1/storefront/orders/", {
"lines": [{"product_id": products["SECRET"].id, "quantity": 1}],
}, format="json", HTTP_X_TENANT_ID=tenant.code,
HTTP_AUTHORIZATION=f"Storefront {token}")
assert resp.status_code == 403
assert resp.json()["code"] == "product_not_authorized"
assert StorefrontOrder.objects.count() == 0
def test_submit_order_blocks_over_credit(db, client, pro_plan, tenant, account, products, granted):
"""额度不足:提交即拦(402),不生成订单。"""
from apps.finance.models import Receivable
baker.make(Receivable, tenant=tenant, customer=account.customer, bill_no="RC-OLD",
bill_date=date.today(), total_amount=Decimal("9900"), status="open")
token = _login(client).json()["token"]
resp = client.post("/api/v1/storefront/orders/", {
"lines": [{"product_id": products["P001"].id, "quantity": 100}], # 300 元
}, format="json", HTTP_X_TENANT_ID=tenant.code,
HTTP_AUTHORIZATION=f"Storefront {token}")
assert resp.status_code == 402, resp.content
assert resp.json()["code"] == "credit_limit_exceeded"
assert StorefrontOrder.objects.count() == 0
def test_submit_order_rejects_zero_qty(db, client, pro_plan, tenant, account, products, granted):
token = _login(client).json()["token"]
resp = client.post("/api/v1/storefront/orders/", {
"lines": [{"product_id": products["P001"].id, "quantity": 0}],
}, format="json", HTTP_X_TENANT_ID=tenant.code,
HTTP_AUTHORIZATION=f"Storefront {token}")
assert resp.status_code == 400
def test_my_orders_list(db, client, pro_plan, tenant, account, products, granted):
token = _login(client).json()["token"]
client.post("/api/v1/storefront/orders/", {
"lines": [{"product_id": products["P001"].id, "quantity": 5}],
}, format="json", HTTP_X_TENANT_ID=tenant.code,
HTTP_AUTHORIZATION=f"Storefront {token}")
resp = client.get("/api/v1/storefront/orders/", HTTP_X_TENANT_ID=tenant.code,
HTTP_AUTHORIZATION=f"Storefront {token}")
assert resp.status_code == 200
assert len(resp.json()["results"]) == 1
# ---------- 内部端流转 ----------
def test_admin_confirm_creates_sales_order_draft(db, auth_client, pro_plan, tenant, account,
products, granted, warehouse):
order = sf_services.submit_order(
account.customer, account=account,
lines=[{"product_id": products["P001"].id, "quantity": 10}],
)
resp = auth_client.post(f"/api/v1/storefront/admin/orders/{order.id}/confirm/",
{}, format="json")
assert resp.status_code == 200, resp.content
body = resp.json()
assert body["ok"] is True
assert body["sales_order_id"]
from apps.sales.models import SalesOrder
so = SalesOrder.objects.get(pk=body["sales_order_id"])
assert so.state == "draft" # 草稿,未过账
assert so.lines.count() == 1
order.refresh_from_db()
assert order.status == "confirmed"
def test_admin_confirm_picks_default_warehouse(db, auth_client, pro_plan, tenant, account,
products, granted, warehouse):
"""不传 warehouse 时自动选默认仓库。"""
order = sf_services.submit_order(
account.customer, lines=[{"product_id": products["P001"].id, "quantity": 1}],
)
resp = auth_client.post(f"/api/v1/storefront/admin/orders/{order.id}/confirm/",
{}, format="json")
assert resp.status_code == 200, resp.content
def test_admin_reject_order(db, auth_client, pro_plan, tenant, account, products, granted):
order = sf_services.submit_order(
account.customer, lines=[{"product_id": products["P001"].id, "quantity": 1}],
)
resp = auth_client.post(f"/api/v1/storefront/admin/orders/{order.id}/reject/",
{"reason": "库存不足"}, format="json")
assert resp.status_code == 200, resp.content
order.refresh_from_db()
assert order.status == "rejected"
assert "库存不足" in order.remark
def test_admin_orders_list_default_submitted(db, auth_client, pro_plan, tenant, account,
products, granted, warehouse):
o1 = sf_services.submit_order(
account.customer, lines=[{"product_id": products["P001"].id, "quantity": 1}],
)
o2 = sf_services.submit_order(
account.customer, lines=[{"product_id": products["P002"].id, "quantity": 1}],
)
sf_services.confirm_order(o2, warehouse=warehouse)
resp = auth_client.get("/api/v1/storefront/admin/orders/")
assert resp.status_code == 200
orders = resp.json()["results"]
assert len(orders) == 1
assert orders[0]["id"] == o1.id
def test_admin_orders_pagination(db, auth_client, pro_plan, tenant, account,
products, granted, warehouse):
"""P2-1:admin_orders 支持 page/page_size 翻页,count 为过滤后总数。"""
ids = []
for _ in range(3):
o = sf_services.submit_order(
account.customer,
lines=[{"product_id": products["P001"].id, "quantity": 1}],
)
ids.append(o.id)
p1 = auth_client.get(
"/api/v1/storefront/admin/orders/",
{"status": "submitted", "page": 1, "page_size": 2},
)
assert p1.status_code == 200, p1.content
b1 = p1.json()
assert b1["count"] == 3
assert len(b1["results"]) == 2
p2 = auth_client.get(
"/api/v1/storefront/admin/orders/",
{"status": "submitted", "page": 2, "page_size": 2},
)
assert p2.status_code == 200, p2.content
b2 = p2.json()
assert b2["count"] == 3
assert len(b2["results"]) == 1
# 两页无重叠
got = {r["id"] for r in b1["results"]} | {r["id"] for r in b2["results"]}
assert got == set(ids)
def test_admin_confirm_twice_rejected(db, auth_client, pro_plan, tenant, account,
products, granted, warehouse):
order = sf_services.submit_order(
account.customer, lines=[{"product_id": products["P001"].id, "quantity": 1}],
)
auth_client.post(f"/api/v1/storefront/admin/orders/{order.id}/confirm/", {}, format="json")
resp2 = auth_client.post(f"/api/v1/storefront/admin/orders/{order.id}/confirm/",
{}, format="json")
assert resp2.status_code == 400
# ---------- 授权管理 ----------
def test_admin_grant_and_revoke(db, auth_client, tenant, customer, products):
resp = auth_client.post("/api/v1/storefront/admin/auths/", {
"customer_id": customer.id,
"product_ids": [products["P001"].id, products["P002"].id],
}, format="json")
assert resp.status_code == 201, resp.content
assert resp.json()["created"] == 2
listing = auth_client.get(
f"/api/v1/storefront/admin/auths/?customer_id={customer.id}"
).json()["results"]
assert len(listing) == 2
auth_id = listing[0]["id"]
resp2 = auth_client.delete(f"/api/v1/storefront/admin/auths/?id={auth_id}")
assert resp2.status_code == 200
# 撤销后不可见
assert not CustomerProductAuth.objects.filter(
pk=auth_id, is_active=True, is_deleted=False
).exists()
def test_admin_create_account(db, auth_client, tenant, customer):
resp = auth_client.post("/api/v1/storefront/admin/accounts/", {
"customer_id": customer.id, "phone": "13700000001",
"password": "abc12345",
}, format="json")
assert resp.status_code == 201, resp.content
acc = StorefrontAccount.objects.get(phone="13700000001")
assert acc.check_password("abc12345")
# ---------- 租户隔离 ----------
def test_cross_tenant_product_not_visible(db, client, pro_plan, tenant, other_tenant,
account, granted, products):
"""另一租户的商品不会出现在本租户商城目录里。"""
other_p = baker.make(Product, tenant=other_tenant, code="X001", name="别家商品",
sale_price=Decimal("1"), status="active")
CustomerProductAuth.objects.create(
tenant=other_tenant, customer=account.customer, product=other_p, is_active=True
) # 即便误建了跨租户授权
token = _login(client).json()["token"]
resp = client.get("/api/v1/storefront/catalog/", HTTP_X_TENANT_ID=tenant.code,
HTTP_AUTHORIZATION=f"Storefront {token}")
codes = {r["code"] for r in resp.json()["results"]}
assert "X001" not in codes
def test_catalog_excludes_inactive_products(db, client, pro_plan, tenant, account,
products, granted):
products["P001"].status = "inactive"
products["P001"].save()
token = _login(client).json()["token"]
resp = client.get("/api/v1/storefront/catalog/", HTTP_X_TENANT_ID=tenant.code,
HTTP_AUTHORIZATION=f"Storefront {token}")
codes = {r["code"] for r in resp.json()["results"]}
assert "P001" not in codes
# ---------- billing 功能开关 ----------
def test_storefront_blocked_on_free_plan(db, client, tenant, account, products, granted):
"""免费版无订货商城 → 登录被 billing 功能开关拒绝(403 + 升级引导)。"""
from apps.billing.models import Plan, Subscription, seed_plans
seed_plans()
free = Plan.objects.get(code="free")
Subscription.objects.create(tenant=tenant, plan=free, status="active",
period_start=date.today(),
period_end=date.today())
resp = _login(client)
assert resp.status_code == 403, resp.content
assert resp.json()["code"] == "quota_exceeded"
assert resp.json()["kind"] == "storefront"
def test_storefront_allowed_on_pro_plan(db, client, tenant, account, products, granted):
from apps.billing.models import Plan, Subscription, seed_plans
seed_plans()
pro = Plan.objects.get(code="pro")
Subscription.objects.create(tenant=tenant, plan=pro, status="active",
period_start=date.today(),
period_end=date.today())
resp = _login(client)
assert resp.status_code == 200, resp.content