Files

259 lines
10 KiB
Python

"""打印中心集成测试:渲染器安全/语法 + 单据上下文 + 渲染 API + 模板/抬头 API。"""
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
from apps.printing import services as printing_services
from apps.printing.models import PrintSettings, PrintTemplate
from apps.printing.renderer import render_template
from apps.sales import services as sales_services
from apps.sales.models import SalesBill
from apps.purchase import services as purchase_services
@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 warehouse(db, tenant):
return baker.make(Warehouse, tenant=tenant, code="WH01", name="主仓")
@pytest.fixture
def product(db, tenant):
# 品名带 HTML/脚本,验证转义
return baker.make(Product, tenant=tenant, code="P<script>1", name="可乐<img src=x onerror=1>",
sale_price=Decimal("5"))
@pytest.fixture
def customer(db, tenant):
return baker.make(Customer, tenant=tenant, code="C001", name="张三商店",
phone="13800000000")
def _make_confirmed_bill(tenant, warehouse, customer, product, round_to=None):
from apps.inventory import services as inv_services
inv_services.inbound(
tenant=tenant, warehouse=warehouse, product=product,
quantity=Decimal("100"), unit_cost=Decimal("3"),
)
bill = sales_services.create_sales_bill(
tenant=tenant, customer=customer, warehouse=warehouse,
lines=[{"product": product, "quantity": 3, "unit_price": 5.52}],
round_to=round_to,
)
return sales_services.confirm_sales_bill(bill)
# ---------- 渲染器 ----------
def test_renderer_dot_path_and_missing():
ctx = {"a": {"b": "x"}, "n": 3, "d": Decimal("12.5")}
assert render_template("{{a.b}} {{n}} {{d}}", ctx) == "x 3 12.5"
assert render_template("[{{a.missing}}]", ctx) == "[]"
assert render_template("[{{nope.deep}}]", ctx) == "[]"
def test_renderer_escapes_html():
ctx = {"name": "<b>&amp;</b>"}
out = render_template("{{name}}", ctx)
assert "<b>" not in out
assert "&lt;b&gt;" in out
def test_renderer_each_loop(tenant, warehouse, customer, product):
bill = _make_confirmed_bill(tenant, warehouse, customer, product)
ctx = printing_services.sales_bill_context(bill)
body = "{{#each lines}}[{{item.product_code}}:{{item.quantity}}]{{/each}}"
out = render_template(body, ctx)
assert out.startswith("[")
assert "P&lt;script&gt;1" in out # 商品编码被转义
assert "<script>" not in out
def test_renderer_does_not_execute_django_tags():
ctx = {"name": "a"}
out = render_template("{% load static %}{{name}}", ctx)
assert "{% load static %}" in out # 原样输出,不执行
assert "a" in out
def test_renderer_list_index_path():
ctx = {"lines": [{"code": "A"}, {"code": "B"}]}
assert render_template("{{lines.1.code}}", ctx) == "B"
# ---------- 上下文 ----------
def test_sales_bill_context_fields(tenant, warehouse, customer, product, db):
bill = _make_confirmed_bill(tenant, warehouse, customer, product, round_to="1")
ctx = printing_services.sales_bill_context(bill)
assert ctx["bill"]["bill_no"] == bill.bill_no
assert ctx["totals"]["net_amount"] == Decimal("16")
assert ctx["totals"]["round_off"] == Decimal("0.56")
assert ctx["totals"]["amount"] == Decimal("16.56")
assert ctx["customer"]["name"] == "张三商店"
assert ctx["lines"][0]["source_quantity"] == Decimal("3")
def test_sales_bill_context_unit_conversion(tenant, warehouse, customer, product, db):
base = baker.make(Unit, tenant=tenant, code="bottle", name="瓶", is_base=True)
product.base_unit = base
product.save()
box = baker.make(Unit, tenant=tenant, code="box", name="箱", is_base=False)
UnitConversion.objects.create(tenant=tenant, product=product, unit=box, rate=Decimal("24"))
from apps.inventory import services as inv_services
inv_services.inbound(tenant=tenant, warehouse=warehouse, product=product,
quantity=Decimal("100"), unit_cost=Decimal("3"))
bill = sales_services.create_sales_bill(
tenant=tenant, customer=customer, warehouse=warehouse,
lines=[{"product": product, "quantity": 1, "unit_price": 120, "source_unit": box}],
)
sales_services.confirm_sales_bill(bill)
ctx = printing_services.sales_bill_context(bill)
assert ctx["lines"][0]["unit_name"] == "箱"
assert ctx["lines"][0]["source_quantity"] == Decimal("1")
assert ctx["lines"][0]["quantity"] == Decimal("24")
# ---------- 渲染 API ----------
def test_render_sales_bill_api(db, auth_client, tenant, warehouse, customer, product):
bill = _make_confirmed_bill(tenant, warehouse, customer, product)
resp = auth_client.get(f"/api/v1/printing/render/sales_bill/{bill.id}/")
assert resp.status_code == 200, resp.content
html_text = resp.content.decode("utf-8")
assert bill.bill_no in html_text
assert "张三商店" in html_text
assert "window.print()" in html_text # 自动打印脚本
assert "<img src=x onerror=1>" not in html_text # 已转义
assert "销 售 单" in html_text
# 默认模板已自动落库
assert PrintTemplate.objects.filter(tenant=tenant, doc_type="sales_bill").exists()
def test_render_custom_template_used(db, auth_client, tenant, warehouse, customer, product):
bill = _make_confirmed_bill(tenant, warehouse, customer, product)
PrintTemplate.objects.create(
tenant=tenant, code="xs-custom", name="我的模板", doc_type="sales_bill",
body_html="<h3>自定义抬头 {{bill.bill_no}}</h3>", is_default=False,
)
resp = auth_client.get(
f"/api/v1/printing/render/sales_bill/{bill.id}/?template=xs-custom"
)
assert resp.status_code == 200
assert "自定义抬头" in resp.content.decode("utf-8")
def test_render_autoprint_off(db, auth_client, tenant, warehouse, customer, product):
bill = _make_confirmed_bill(tenant, warehouse, customer, product)
resp = auth_client.get(f"/api/v1/printing/render/sales_bill/{bill.id}/?autoprint=0")
assert "window.addEventListener" not in resp.content.decode("utf-8") # 自动打印脚本关闭
assert "打 印" in resp.content.decode("utf-8") # 手动按钮仍在
def test_render_tenant_isolation(db, auth_client, tenant, other_tenant, warehouse, customer, product):
from apps.core.models import TenantMembership
bill = _make_confirmed_bill(tenant, warehouse, customer, product)
c2 = APIClient()
bob = __import__("django").contrib.auth.get_user_model().objects.create_user("bob2", "pass12345")
# bob2 must be *authorized* for other_tenant: the point of this case is that
# a cross-tenant object lookup returns 404 (no existence leak), not 403.
TenantMembership.objects.create(user=bob, tenant=other_tenant, role="member")
refresh = RefreshToken.for_user(bob)
c2.credentials(
HTTP_AUTHORIZATION=f"Bearer {refresh.access_token}",
HTTP_X_TENANT_ID=other_tenant.code,
)
resp = c2.get(f"/api/v1/printing/render/sales_bill/{bill.id}/")
assert resp.status_code == 404
def test_render_purchase_bill_api(db, auth_client, tenant, warehouse, product):
from apps.partner.models import Supplier
supplier = baker.make(Supplier, tenant=tenant, code="S001", name="上游厂")
bill = purchase_services.create_purchase_bill(
tenant=tenant, supplier=supplier, warehouse=warehouse,
lines=[{"product": product, "quantity": 10, "unit_price": 3}],
)
purchase_services.confirm_purchase_bill(bill)
resp = auth_client.get(f"/api/v1/printing/render/purchase_bill/{bill.id}/")
assert resp.status_code == 200
html_text = resp.content.decode("utf-8")
assert "进 货 单" in html_text
assert "上游厂" in html_text
assert bill.bill_no in html_text
def test_render_bad_doc_type_400(db, auth_client, tenant):
resp = auth_client.get("/api/v1/printing/render/nope/1/")
assert resp.status_code == 400
# ---------- 模板与抬头 API ----------
def test_template_crud_api(db, auth_client, tenant):
resp = auth_client.post("/api/v1/printing/templates/", {
"code": "xs-test", "name": "测试模板", "doc_type": "sales_bill",
"body_html": "{{bill.bill_no}}", "is_default": False,
}, format="json")
assert resp.status_code == 201, resp.content
tid = resp.json()["id"]
resp2 = auth_client.get("/api/v1/printing/templates/")
assert resp2.status_code == 200 and resp2.json()["count"] >= 1
resp3 = auth_client.patch(f"/api/v1/printing/templates/{tid}/",
{"name": "改名"}, format="json")
assert resp3.status_code == 200
def test_settings_get_put_api(db, auth_client, tenant):
resp = auth_client.get("/api/v1/printing/settings/default/")
assert resp.status_code == 200, resp.content
data = resp.json()
assert "company_name" in data
resp2 = auth_client.put("/api/v1/printing/settings/default/", {
"company_name": "演示经销商", "phone": "123", "address": "地址",
"bank_info": "行号", "footer_note": "货已验收", "logo_url": "",
}, format="json")
assert resp2.status_code == 200, resp2.content
# 抬头进入渲染输出
from apps.inventory.models import Warehouse as W
wh = baker.make(W, tenant=tenant, code="WH9", name="仓9")
prod = baker.make(Product, tenant=tenant, code="PP9", name="货9", sale_price=Decimal("1"))
cust = baker.make(Customer, tenant=tenant, code="CC9", name="客9")
bill = _make_confirmed_bill(tenant, wh, cust, prod)
resp3 = auth_client.get(f"/api/v1/printing/render/sales_bill/{bill.id}/")
assert "演示经销商" in resp3.content.decode("utf-8")
assert "货已验收" in resp3.content.decode("utf-8")
def test_settings_bad_tenant_400(db, user, tenant):
"""租户头无效时返回 400(由 adrf 异常处理包装)。"""
from rest_framework_simplejwt.tokens import RefreshToken
c = APIClient()
c.credentials(HTTP_AUTHORIZATION=f"Bearer {RefreshToken.for_user(user).access_token}",
HTTP_X_TENANT_ID="no-such-tenant")
resp = c.get("/api/v1/printing/settings/default/")
assert resp.status_code in (400, 401, 403)