baseline: 批次A-D 成果 + membership 半成品(测试红)

This commit is contained in:
agent
2026-09-11 23:11:35 +08:00
commit b3f3095d53
311 changed files with 40540 additions and 0 deletions
+494
View File
@@ -0,0 +1,494 @@
"""批次 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_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