"""全局异常处理器测试(迭代第 2 轮)。 验证:业务异常 → 正确的 HTTP 语义与 `code`;未知异常 → 结构化 500(不泄漏栈)。 """ import pytest 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 from apps.core.exceptions import api_exception_handler from apps.core.services import ( BelowMinPrice, InvalidLinePrice, InvalidLineQuantity, ) from apps.inventory.models import Warehouse from apps.inventory.services import InsufficientStock from apps.partner.models import Customer from apps.sales import services as sales_services from apps.sales.services import CreditLimitExceeded @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): return baker.make(Product, tenant=tenant, code="P001", name="可乐", sale_price=Decimal("10")) @pytest.fixture def customer(db, tenant): return baker.make(Customer, tenant=tenant, code="C001", name="张三", credit_limit=Decimal("10000")) # ============================================================ # 单元级:处理器映射正确性 # ============================================================ def _call(exc): return api_exception_handler(exc, {"view": None, "request": None}) def test_handler_maps_invalid_quantity(): exc = InvalidLineQuantity("P001", -5, "数量必须大于 0") resp = _call(exc) assert resp.status_code == 400 assert resp.data["code"] == "invalid_quantity" assert resp.data["product_code"] == "P001" def test_handler_maps_invalid_price(): exc = InvalidLinePrice("P001", -1, "单价不能为负") resp = _call(exc) assert resp.status_code == 400 assert resp.data["code"] == "invalid_price" def test_handler_maps_below_min_price(): exc = BelowMinPrice("P001", Decimal("5"), Decimal("8")) resp = _call(exc) assert resp.status_code == 400 assert resp.data["code"] == "below_min_price" assert resp.data["unit_price"] == "5" assert resp.data["min_price"] == "8" def test_handler_maps_insufficient_stock(): resp = _call(InsufficientStock("no stock")) assert resp.status_code == 400 assert resp.data["code"] == "insufficient_stock" def test_handler_maps_credit_limit(): exc = CreditLimitExceeded("C001", Decimal("900"), Decimal("1000"), Decimal("200")) resp = _call(exc) assert resp.status_code == 402 assert resp.data["code"] == "credit_limit_exceeded" assert resp.data["customer_code"] == "C001" def test_handler_returns_structured_500_for_unknown(db): """未知异常 → 结构化 500,且**不把栈信息/原始消息**给客户端。""" resp = _call(RuntimeError("内部数据库连接串 postgres://user:pw@host/db 泄露了")) assert resp.status_code == 500 assert resp.data["code"] == "server_error" assert "exc_type" not in resp.data body = str(resp.data) assert "postgres://" not in body # 原始消息不外泄 assert "Traceback" not in body def test_handler_passes_through_drf_exceptions(db, rf): """DRF 内置异常(ValidationError)由 DRF 处理,不被我们的兜底覆盖。""" from rest_framework.exceptions import ValidationError resp = _call(ValidationError({"field": "必填"})) assert resp.status_code == 400 assert "field" in resp.data # ============================================================ # 集成级:真实 API 路径 # ============================================================ def test_api_invalid_quantity_returns_code(db, auth_client, customer, warehouse, product): resp = auth_client.post("/api/v1/sales/bills/create-bill/", { "customer": customer.id, "warehouse": warehouse.id, "lines": [{"product": product.id, "quantity": "-5", "unit_price": "10"}], }, format="json") assert resp.status_code == 400 assert resp.json()["code"] == "invalid_quantity" def test_api_insufficient_stock_returns_code(db, auth_client, customer, warehouse, product): """库存不足:经由全局处理器返回 400 + code(此前视图里有重复分支)。""" bill = auth_client.post("/api/v1/sales/bills/create-bill/", { "customer": customer.id, "warehouse": warehouse.id, "lines": [{"product": product.id, "quantity": "999", "unit_price": "10"}], }, format="json").json() resp = auth_client.post(f"/api/v1/sales/bills/{bill['id']}/confirm/", {}, format="json") assert resp.status_code == 400 assert resp.json()["code"] == "insufficient_stock" def test_api_credit_limit_returns_402(db, auth_client, tenant, warehouse, product): """信用超限:402 + code(前端据此弹强制过账确认框)。""" from apps.finance.models import Receivable from apps.inventory import services as inv limited = baker.make(Customer, tenant=tenant, code="C999", name="受限", credit_limit=Decimal("100")) baker.make(Receivable, tenant=tenant, customer=limited, bill_no="RC-1", total_amount=Decimal("90"), status="open") inv.inbound(tenant=tenant, warehouse=warehouse, product=product, quantity=Decimal("100"), unit_cost=Decimal("2")) bill = auth_client.post("/api/v1/sales/bills/create-bill/", { "customer": limited.id, "warehouse": warehouse.id, "lines": [{"product": product.id, "quantity": 50, "unit_price": "10"}], }, format="json").json() resp = auth_client.post(f"/api/v1/sales/bills/{bill['id']}/confirm/", {}, format="json") assert resp.status_code == 402 assert resp.json()["code"] == "credit_limit_exceeded" def test_api_quota_exceeded_returns_403(db, auth_client, tenant, settings): """AI 配额超限:403 + 升级引导(此前视图里有重复分支)。""" from apps.ai import usage as ai_usage from apps.billing.models import Plan, Subscription, seed_plans seed_plans() plan, _ = Plan.objects.update_or_create( code="quota-test", defaults={ "name": "配额测试", "price_monthly": Decimal("0"), "limits": {"users": 9, "products": 9, "bills_monthly": 0, "ai_parse_order": 1, "ai_ask": 1, "batch_managed": True, "finance_ledger": True, "print_templates": 0, "storefront": False}, "sort_order": 99, "is_active": True, }, ) Subscription.objects.update_or_create( tenant=tenant, defaults={"plan": plan, "status": "active"}, ) ai_usage.record_usage(tenant, ai_usage.KIND_ASK) settings.AI_PROVIDER = "" settings.AI_API_KEY = "" resp = auth_client.post("/api/v1/ai/ask/", {"question": "销售额"}, format="json") assert resp.status_code == 403 body = resp.json() assert body["code"] == "quota_exceeded" assert body["upgrade_url"] == "/#/pricing" def test_api_storefront_error_returns_400(db, client, tenant, settings): """商城业务错误(空明细)→ 400 + code。""" from apps.billing.models import Plan, Subscription, seed_plans seed_plans() pro = Plan.objects.get(code="pro") Subscription.objects.update_or_create( tenant=tenant, defaults={"plan": pro, "status": "active"}, ) resp = client.post("/api/v1/storefront/orders/", {"lines": []}, format="json", HTTP_X_TENANT_ID=tenant.code) assert resp.status_code < 500 assert resp.json().get("code") in ("invalid_order", "unauthorized") # ============================================================ # 审计日志(迭代第 3 轮) # ============================================================ def test_audit_log_written_on_sales_post(db, tenant, warehouse, customer, product): """销售过账必须留审计(谁、何时、哪张单、金额多少)。""" from apps.core.audit import query_logs from apps.core.models import AuditLog from apps.inventory import services as inv inv.inbound(tenant=tenant, warehouse=warehouse, product=product, quantity=Decimal("100"), unit_cost=Decimal("5")) bill = sales_services.create_sales_bill( tenant=tenant, customer=customer, warehouse=warehouse, lines=[{"product": product, "quantity": 3, "unit_price": 10}], ) sales_services.confirm_sales_bill(bill) logs = query_logs(tenant, target_type="SalesBill", target_id=bill.bill_no) assert logs, "过账未写审计日志" entry = logs[0] assert entry.detail["business_action"] == "post" assert entry.detail["bill_no"] == bill.bill_no assert entry.detail["amount"] == str(bill.total_amount) def test_audit_log_written_on_purchase_post(db, tenant, warehouse, product): """采购过账同样留痕。""" from apps.core.audit import query_logs from apps.partner.models import Supplier from apps.purchase import services as purchase_services 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": 5, "unit_price": 3}], ) purchase_services.confirm_purchase_bill(bill) logs = query_logs(tenant, target_type="PurchaseBill", target_id=bill.bill_no) assert logs and logs[0].detail["business_action"] == "post" def test_audit_log_written_on_credit_force_release(db, tenant, warehouse, product): """信用超限强制放行 → 必须留痕(高风险动作,事后要能追责)。""" from apps.core.audit import query_logs from apps.finance.models import Receivable from apps.inventory import services as inv limited = baker.make(Customer, tenant=tenant, code="C777", name="受限客户", credit_limit=Decimal("100")) baker.make(Receivable, tenant=tenant, customer=limited, bill_no="RC-9", total_amount=Decimal("90"), status="open") inv.inbound(tenant=tenant, warehouse=warehouse, product=product, quantity=Decimal("100"), unit_cost=Decimal("2")) bill = sales_services.create_sales_bill( tenant=tenant, customer=limited, warehouse=warehouse, lines=[{"product": product, "quantity": 5, "unit_price": 10}], ) sales_services.confirm_sales_bill(bill, force=True) logs = query_logs(tenant, target_type="credit_limit", target_id=bill.bill_no) assert logs, "强制放行未留审计" assert logs[0].detail["business_action"] == "force" assert logs[0].detail["customer_code"] == "C777" def test_audit_log_written_on_below_min_price_release(db, tenant, warehouse, product): """低于最低售价放行 → 留痕(放行可能亏本)。""" from apps.core.audit import query_logs from apps.core.services import check_min_price product.min_sale_price = Decimal("8") product.save() check_min_price(product, unit_price=Decimal("5"), allow_below_min=True) logs = query_logs(tenant, target_type="below_min_price", target_id=product.code) assert logs, "低价放行未留审计" assert logs[0].detail["min_price"] == "8" def test_audit_log_failure_does_not_break_business(db, tenant, monkeypatch): """审计写失败**不能**影响主业务(旁路设计)。""" from apps.core import audit as audit_log def boom(**kwargs): raise RuntimeError("审计服务不可用") monkeypatch.setattr(audit_log.AuditLog.objects, "create", boom) # 不应抛异常 result = audit_log.log(tenant=tenant, action="post", target_type="Test", target_id="X1") assert result is None # 失败返回 None def test_audit_records_request_context(db, tenant, rf, django_user_model): """给了 request 就自动带 user / ip / user_agent。""" from apps.core import audit as audit_log from apps.core.models import AuditLog user = django_user_model.objects.create_user("auditor", password="x") req = rf.post("/api/v1/sales/bills/1/confirm/", REMOTE_ADDR="10.1.2.3", HTTP_USER_AGENT="TestAgent/1.0") req.user = user audit_log.log(tenant=tenant, action="post", target_type="SalesBill", target_id="XS-1", request=req) entry = AuditLog.objects.filter(tenant=tenant, target_id="XS-1").first() assert entry is not None assert entry.user_id == user.id assert str(entry.ip) == "10.1.2.3" assert entry.user_agent == "TestAgent/1.0" def test_audit_query_filters_by_target(db, tenant, warehouse, customer, product): """按单据号查轨迹("这张单都发生了什么")。""" from apps.core.audit import query_logs from apps.inventory import services as inv inv.inbound(tenant=tenant, warehouse=warehouse, product=product, quantity=Decimal("100"), unit_cost=Decimal("5")) b1 = sales_services.create_sales_bill( tenant=tenant, customer=customer, warehouse=warehouse, lines=[{"product": product, "quantity": 1, "unit_price": 10}], ) b2 = sales_services.create_sales_bill( tenant=tenant, customer=customer, warehouse=warehouse, lines=[{"product": product, "quantity": 2, "unit_price": 10}], ) sales_services.confirm_sales_bill(b1) sales_services.confirm_sales_bill(b2) logs = query_logs(tenant, target_type="SalesBill", target_id=b1.bill_no) assert len(logs) == 1 assert logs[0].target_id == b1.bill_no assert logs[0].target_id != b2.bill_no def test_audit_tenant_isolation(db, tenant, other_tenant, warehouse, customer, product): """审计日志按租户隔离。""" from apps.core.audit import log, query_logs from apps.inventory import services as inv inv.inbound(tenant=tenant, warehouse=warehouse, product=product, quantity=Decimal("10"), unit_cost=Decimal("1")) bill = sales_services.create_sales_bill( tenant=tenant, customer=customer, warehouse=warehouse, lines=[{"product": product, "quantity": 1, "unit_price": 10}], ) sales_services.confirm_sales_bill(bill) assert query_logs(tenant, target_type="SalesBill") assert query_logs(other_tenant, target_type="SalesBill") == [] # ============================================================ # 请求上下文透传(审计取 IP/UA) # ============================================================ def test_service_layer_gets_request_via_contextvar(db, tenant, warehouse, customer, product, rf, django_user_model): """服务层没有 request 参数,但审计仍能记录 IP/UA(经 contextvar 透传)。""" from apps.core import audit as audit_log from apps.core.context import clear_current_request, set_current_request from apps.core.models import AuditLog from apps.inventory import services as inv user = django_user_model.objects.create_user("ctx_user", password="x") inv.inbound(tenant=tenant, warehouse=warehouse, product=product, quantity=Decimal("100"), unit_cost=Decimal("5")) req = rf.post("/api/v1/sales/bills/1/confirm/", REMOTE_ADDR="192.168.9.9", HTTP_USER_AGENT="CtxAgent/2.0") req.user = user set_current_request(req) try: bill = sales_services.create_sales_bill( tenant=tenant, customer=customer, warehouse=warehouse, lines=[{"product": product, "quantity": 1, "unit_price": 10}], ) # 注意:这里没有传 request/user 给服务层 sales_services.confirm_sales_bill(bill) finally: clear_current_request() entry = AuditLog.objects.filter(target_id=bill.bill_no).first() assert entry is not None assert str(entry.ip) == "192.168.9.9" # 从 contextvar 拿到 assert entry.user_agent == "CtxAgent/2.0" assert entry.user_id == user.id def test_contextvar_cleared_between_requests(db, tenant, rf): """请求结束必须清除(防止跨请求串号)。""" from apps.core.audit import log from apps.core.context import clear_current_request, current_request, set_current_request from apps.core.models import AuditLog import django.contrib.auth as auth assert current_request() is None req = rf.get("/x/", REMOTE_ADDR="1.1.1.1") set_current_request(req) assert current_request() is req clear_current_request() assert current_request() is None # 清除后再写审计:不应带上被清除请求的 IP log(tenant=tenant, action="post", target_type="T", target_id="NOCTX") entry = AuditLog.objects.filter(target_id="NOCTX").first() assert entry is not None assert entry.ip is None def test_audit_tolerates_missing_request(db, tenant): """无请求上下文(如后台任务)时审计仍能写入,只是没有 IP/UA。""" from apps.core.audit import log from apps.core.models import AuditLog log(tenant=tenant, action="post", target_type="CronJob", target_id="JOB-1", detail={"note": "定时任务"}) entry = AuditLog.objects.filter(target_id="JOB-1").first() assert entry is not None assert entry.ip is None assert entry.user_id is None assert entry.detail["note"] == "定时任务"