"""P0-4 · 重复编码 400 回归(28 模型一处修复)。 根因:DRF 的 `get_unique_together_validators()` 要求 unique_together 字段 全部出现在 `Meta.fields` 中;本项目 `tenant` 由 `BaseTenantViewSet.acreate()` 注入、不在 fields 里 → 校验器被静默丢弃 → 唯一冲突直达 DB → 500。 修复落点:`apps/core/viewset.py` 的 `acreate()` —— 租户感知的唯一性预检 (友好 400)+ `IntegrityError` 并发兜底(转 400)。 注意:会触发 DB 异常的回归测试一律用 `@pytest.mark.django_db(transaction=True)`,否则测试自带的外层 atomic 被污染,后续 ORM 全变 `TransactionManagementError`(测试假象,非生产问题)。 """ import pytest from rest_framework.test import APIClient from rest_framework_simplejwt.tokens import RefreshToken pytestmark = pytest.mark.django_db(transaction=True) def _jwt_client(user, tenant_code): c = APIClient() c.credentials( HTTP_AUTHORIZATION=f"Bearer {RefreshToken.for_user(user).access_token}", HTTP_X_TENANT_ID=tenant_code, ) return c # (url, payload) —— 覆盖主数据 8 类高频手输编码 DUPLICATE_CASES = [ ("/api/v1/catalog/categories/", {"code": "DUP01", "name": "分类A"}), ("/api/v1/catalog/brands/", {"code": "DUP01", "name": "品牌A"}), ("/api/v1/catalog/units/", {"code": "DUP01", "name": "单位A"}), ("/api/v1/catalog/products/", {"code": "DUP01", "name": "商品A"}), ("/api/v1/partner/price-levels/", {"code": "DUP01", "name": "等级A"}), ("/api/v1/partner/customers/", {"code": "DUP01", "name": "客户A"}), ("/api/v1/partner/suppliers/", {"code": "DUP01", "name": "供应商A"}), ("/api/v1/inventory/warehouses/", {"code": "DUP01", "name": "仓库A"}), ] @pytest.mark.parametrize("url,payload", DUPLICATE_CASES) def test_duplicate_code_is_400_not_500(db, tenant, user, url, payload): """重复编码 → 400 + 字段级错误,不含 exc_type/堆栈。""" c = _jwt_client(user, tenant.code) r1 = c.post(url, {**payload, "name": payload["name"] + "一"}, format="json") assert r1.status_code == 201, (url, r1.content) r2 = c.post(url, {**payload, "name": payload["name"] + "二"}, format="json") assert r2.status_code == 400, (url, r2.content) body = r2.json() assert "exc_type" not in body assert "traceback" not in str(body).lower() def test_duplicate_code_is_tenant_scoped(db, tenant, other_tenant, user): """跨租户同编码允许:唯一性是租户内的,不是全局的。""" from apps.core.models import TenantMembership TenantMembership.objects.get_or_create( user=user, tenant=other_tenant, defaults={"role": "member", "is_active": True}, ) c1 = _jwt_client(user, tenant.code) c2 = _jwt_client(user, other_tenant.code) r1 = c1.post( "/api/v1/catalog/products/", {"code": "SHARED01", "name": "甲租户商品"}, format="json", ) assert r1.status_code == 201, r1.content r2 = c2.post( "/api/v1/catalog/products/", {"code": "SHARED01", "name": "乙租户商品"}, format="json", ) assert r2.status_code == 201, r2.content def test_unknown_error_has_no_exc_type(db, tenant, user): """兜底 500 不泄露 exc_type/原始消息(P0-4 附带收尾,已在 exceptions 落地)。""" from rest_framework.test import APIRequestFactory from apps.core.exceptions import api_exception_handler factory = APIRequestFactory() req = factory.get("/api/v1/catalog/products/") resp = api_exception_handler(RuntimeError("boom-secret"), {"request": req, "view": None}) assert resp.status_code == 500 assert resp.data["code"] == "server_error" assert "exc_type" not in resp.data assert "boom-secret" not in str(resp.data)