259 lines
10 KiB
Python
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>&</b>"}
|
|
out = render_template("{{name}}", ctx)
|
|
assert "<b>" not in out
|
|
assert "<b>" 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<script>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)
|