Просмотр исходного кода

test: S-cfc-200 admin.supplier.manage RED 契约测试(410 壳/边界门禁/role 守卫,19 用例 16 红/3 绿)

Sisyphus Agent 1 час назад
Родитель
Сommit
5ad781a003

+ 445 - 0
cfc-backend/src/test/java/com/etotem/cfc/unit/SupplierManageAdminContractTest.java

@@ -0,0 +1,445 @@
+package com.etotem.cfc.unit;
+
+import com.etotem.cfc.common.Result;
+import com.etotem.cfc.controller.admin.AdminSupplierController;
+import com.etotem.cfc.controller.admin.EcomSupplierController;
+import com.etotem.cfc.controller.admin.SupplyManageController;
+import com.etotem.cfc.controller.admin.SupplierProductController;
+import com.etotem.cfc.dto.ProductCreateRequest;
+import com.etotem.cfc.entity.Product;
+import com.etotem.cfc.entity.SupplySystem;
+import com.etotem.cfc.entity.User;
+import com.etotem.cfc.mapper.UserMapper;
+import com.etotem.cfc.service.ProductService;
+import com.etotem.cfc.service.RealNameBackfillService;
+import com.etotem.cfc.service.SupplySystemService;
+import com.etotem.cfc.service.UserService;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Test;
+import org.mockito.Mock;
+import org.mockito.MockitoAnnotations;
+import org.springframework.web.bind.annotation.PostMapping;
+import org.springframework.web.bind.annotation.RequestAttribute;
+
+import java.util.Collections;
+import java.util.HashMap;
+import java.util.Map;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertNotNull;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.Mockito.never;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+
+/**
+ * 契约级单测:管理端供应商管理(能力键 {@code admin.supplier.manage},TDD-RED 阶段)。
+ *
+ * <p><b>需求/设计溯源</b></p>
+ * <ul>
+ *   <li>场景:{@code tests/user-scenarios/应用场景总览.md} SC-A-06(供应商管理)、
+ *       SC-S-01(入驻申请)、SC-S-02(商品管理上架/编辑);</li>
+ *   <li>用例:{@code tests/test-cases/vendor.md} TC-S-01~TC-S-02-E;</li>
+ *   <li>设计:{@code docs/superpowers/api/API_REFERENCE.md} §4.13 + §4.60.6 + §4.62.1 + §4.62.2;</li>
+ *   <li>任务登记:sc-key-map.md「SC-A-06 / SC-S-01 / SC-S-02 → admin.supplier.manage → S-cfc-200」。</li>
+ * </ul>
+ *
+ * <p><b>被校验的契约行为</b></p>
+ * <ol>
+ *   <li><b>正常流(结构锚点)</b>:
+ *       {@code AdminSupplierController} /api/admin/supplier(list/save/update/delete)、
+ *       {@code EcomSupplierController} /api/admin/ecom-supplier(list/all/save/update/delete)、
+ *       {@code SupplyManageController} /api/admin/supply-manage(list/detail/create/update/delete)
+ *       均应暴露约定 POST 端点。</li>
+ *   <li><b>异常流 / 边界流(TC-S-01-E / SC-A-06 / SC-S-02-E)</b>:
+ *       <ul>
+ *         <li>AdminSupplierController / EcomSupplierController 当前均为 <b>410 废弃壳</b>
+ *             (「该接口已废弃,请使用 /api/admin/supply-system/*」),
+ *             契约要求此路径下的 CRUD 提供真实功能而非 410;</li>
+ *         <li>SupplyManageController.delete 缺乏「有在售商品则拒绝删除/停用」门禁(SC-A-06 边界:
+ *             有在售商品的供应商停用需先下架);</li>
+ *         <li>SupplyManageController.create 对无完整资质的申请仍自动设为
+ *             {@code vendorStatus=approved},违反 SC-S-01 的「提交资质 → 平台审核 → 入驻成功」流转
+ *             (资质不全应驳回,不得自动通过);</li>
+ *         <li>SupplierProductController.create 未拦截负价格/负库存(TC-S-02-E),产品经
+ *             {@code ProductService.adminCreate} 直接以 {@code on_shelf} 状态落库。</li>
+ *       </ul></li>
+ *   <li><b>权限流</b>:三个控制器方法均未带 {@code @RequestAttribute("role")} 守卫(对标
+ *       {@code AdminAuditController}/{@code AdminArticleController} 模式),非 admin 无法 403 拒绝
+ *       (TC-S-01-P / SC-A-06-P)。</li>
+ * </ol>
+ *
+ * <p><b>RED 预期</b>:结构锚点用例通过(绿,基线);异常/边界/权限用例断言失败(红),
+ * 断言原文清晰标注契约所需的「非 410/CURD 落地/业务门禁/role 守卫」缺失。
+ * GREEN 阶段实现后本测试自动转绿。
+ *
+ * <p><b>测试范围</b>:纯 Mockito + 反射,无 Spring 上下文 / 无 DB / 无外部服务。</p>
+ *
+ * @see <a href="https://git.iwintrue.com/cfc/superpowers/-/wikis/tdd/contract-tests">TDD 契约测试规范</a>
+ */
+@SuppressWarnings("unchecked")
+class SupplierManageAdminContractTest extends EnergyContractTestBase {
+
+    // ==================== 被校验对象 ====================
+
+    private SupplyManageController supplyManageController;
+    @Mock
+    private UserMapper userMapper;
+    @Mock
+    private UserService userService;
+    @Mock
+    private RealNameBackfillService realNameBackfillService;
+
+    private AdminSupplierController adminSupplierController;
+    private EcomSupplierController ecomSupplierController;
+
+    private SupplierProductController supplierProductController;
+    @Mock
+    private SupplySystemService supplySystemService;
+    @Mock
+    private ProductService productService;
+
+    @BeforeEach
+    void setUp() throws Exception {
+        // MyBatis-Plus TableInfo 预注册:使 LambdaQueryWrapper 在无 Spring 上下文中可解析列名
+        initTable(User.class);
+
+        MockitoAnnotations.openMocks(this);
+
+        // --- SupplyManageController ---
+        supplyManageController = new SupplyManageController();
+        inject(supplyManageController, "userMapper", userMapper);
+        inject(supplyManageController, "userService", userService);
+        inject(supplyManageController, "realNameBackfillService", realNameBackfillService);
+
+        // --- AdminSupplierController / EcomSupplierController ---
+        adminSupplierController = new AdminSupplierController();
+        ecomSupplierController = new EcomSupplierController();
+
+        // --- SupplierProductController ---
+        supplierProductController = new SupplierProductController();
+        inject(supplierProductController, "supplySystemService", supplySystemService);
+        inject(supplierProductController, "productService", productService);
+    }
+
+    // ==================== 正常流(结构锚点,SC-A-06 + SC-S-01) ====================
+
+    @Test
+    @DisplayName("SC-A-06 正常流(结构锚点):SupplyManageController 暴露 list/detail/create/update/delete")
+    void supplyManage_exposesRequiredEndpoints() {
+        assertTrue(hasEndpoint(SupplyManageController.class, "/list"),
+                "RED[admin.supplier.manage] SupplyManageController 应暴露 POST /api/admin/supply-manage/list");
+        assertTrue(hasEndpoint(SupplyManageController.class, "/detail"),
+                "RED[admin.supplier.manage] SupplyManageController 应暴露 POST /api/admin/supply-manage/detail");
+        assertTrue(hasEndpoint(SupplyManageController.class, "/create"),
+                "RED[admin.supplier.manage] SupplyManageController 应暴露 POST /api/admin/supply-manage/create");
+        assertTrue(hasEndpoint(SupplyManageController.class, "/update"),
+                "RED[admin.supplier.manage] SupplyManageController 应暴露 POST /api/admin/supply-manage/update");
+        assertTrue(hasEndpoint(SupplyManageController.class, "/delete"),
+                "RED[admin.supplier.manage] SupplyManageController 应暴露 POST /api/admin/supply-manage/delete");
+    }
+
+    @Test
+    @DisplayName("SC-A-06 正常流(结构锚点):AdminSupplierController 暴露 list/save/update/delete")
+    void adminSupplier_exposesRequiredEndpoints() {
+        assertTrue(hasEndpoint(AdminSupplierController.class, "/list"),
+                "RED[admin.supplier.manage] AdminSupplierController 应暴露 POST /api/admin/supplier/list");
+        assertTrue(hasEndpoint(AdminSupplierController.class, "/save"),
+                "RED[admin.supplier.manage] AdminSupplierController 应暴露 POST /api/admin/supplier/save");
+        assertTrue(hasEndpoint(AdminSupplierController.class, "/update"),
+                "RED[admin.supplier.manage] AdminSupplierController 应暴露 POST /api/admin/supplier/update");
+        assertTrue(hasEndpoint(AdminSupplierController.class, "/delete"),
+                "RED[admin.supplier.manage] AdminSupplierController 应暴露 POST /api/admin/supplier/delete");
+    }
+
+    @Test
+    @DisplayName("SC-S-01 正常流(结构锚点):EcomSupplierController 暴露 list/all/save/update/delete")
+    void ecomSupplier_exposesRequiredEndpoints() {
+        assertTrue(hasEndpoint(EcomSupplierController.class, "/list"),
+                "RED[admin.supplier.manage] EcomSupplierController 应暴露 POST /api/admin/ecom-supplier/list");
+        assertTrue(hasEndpoint(EcomSupplierController.class, "/all"),
+                "RED[admin.supplier.manage] EcomSupplierController 应暴露 POST /api/admin/ecom-supplier/all");
+        assertTrue(hasEndpoint(EcomSupplierController.class, "/save"),
+                "RED[admin.supplier.manage] EcomSupplierController 应暴露 POST /api/admin/ecom-supplier/save");
+        assertTrue(hasEndpoint(EcomSupplierController.class, "/update"),
+                "RED[admin.supplier.manage] EcomSupplierController 应暴露 POST /api/admin/ecom-supplier/update");
+        assertTrue(hasEndpoint(EcomSupplierController.class, "/delete"),
+                "RED[admin.supplier.manage] EcomSupplierController 应暴露 POST /api/admin/ecom-supplier/delete");
+    }
+
+    // ==================== 异常流(SC-A-06):AdminSupplierController 不应是 410 废弃壳 ====================
+
+    @Test
+    @DisplayName("SC-A-06 异常:AdminSupplierController.list 应提供真实功能,非 410 废弃壳")
+    void adminSupplier_list_notDeprecated410() {
+        Result<?> r = adminSupplierController.list(Collections.emptyMap());
+        assertNotNull(r);
+        assertFalse(isDeprecated410(r),
+                "RED[admin.supplier.manage] /api/admin/supplier/list 应提供真实功能(SC-A-06);"
+                        + "当前返回 code=" + r.getCode()
+                        + "(410 废弃壳)。契约要求实现真实 CRUD 而非仅返回 410 提示转向 supply-system。");
+    }
+
+    @Test
+    @DisplayName("SC-A-06 异常:AdminSupplierController.save 应提供真实功能,非 410 废弃壳")
+    void adminSupplier_save_notDeprecated410() {
+        Result<?> r = adminSupplierController.save(Collections.emptyMap());
+        assertNotNull(r);
+        assertFalse(isDeprecated410(r),
+                "RED[admin.supplier.manage] /api/admin/supplier/save 应提供真实功能(SC-A-06);"
+                        + "当前返回 code=" + r.getCode() + "(410 废弃壳)。");
+    }
+
+    @Test
+    @DisplayName("SC-A-06 异常:AdminSupplierController.update 应提供真实功能,非 410 废弃壳")
+    void adminSupplier_update_notDeprecated410() {
+        Result<?> r = adminSupplierController.update(Collections.emptyMap());
+        assertNotNull(r);
+        assertFalse(isDeprecated410(r),
+                "RED[admin.supplier.manage] /api/admin/supplier/update 应提供真实功能(SC-A-06);"
+                        + "当前返回 code=" + r.getCode() + "(410 废弃壳)。");
+    }
+
+    @Test
+    @DisplayName("SC-A-06 异常:AdminSupplierController.delete 应提供真实功能,非 410 废弃壳")
+    void adminSupplier_delete_notDeprecated410() {
+        Result<?> r = adminSupplierController.delete(Collections.emptyMap());
+        assertNotNull(r);
+        assertFalse(isDeprecated410(r),
+                "RED[admin.supplier.manage] /api/admin/supplier/delete 应提供真实功能(SC-A-06);"
+                        + "当前返回 code=" + r.getCode() + "(410 废弃壳)。");
+    }
+
+    // ==================== 异常流(SC-S-01):EcomSupplierController 不应是 410 废弃壳 ====================
+
+    @Test
+    @DisplayName("SC-S-01 异常:EcomSupplierController.list 应提供真实功能,非 410 废弃壳")
+    void ecomSupplier_list_notDeprecated410() {
+        Result<?> r = ecomSupplierController.list(Collections.emptyMap());
+        assertNotNull(r);
+        assertFalse(isDeprecated410(r),
+                "RED[admin.supplier.manage] /api/admin/ecom-supplier/list 应提供真实功能(SC-S-01 入驻申请载体);"
+                        + "当前返回 code=" + r.getCode() + "(410 废弃壳)。");
+    }
+
+    @Test
+    @DisplayName("SC-S-01 异常:EcomSupplierController.all 应提供真实功能,非 410 废弃壳")
+    void ecomSupplier_all_notDeprecated410() {
+        Result<?> r = ecomSupplierController.all();
+        assertNotNull(r);
+        assertFalse(isDeprecated410(r),
+                "RED[admin.supplier.manage] /api/admin/ecom-supplier/all 应提供真实功能(SC-S-01 下拉选择载体);"
+                        + "当前返回 code=" + r.getCode() + "(410 废弃壳)。");
+    }
+
+    @Test
+    @DisplayName("SC-S-01 异常:EcomSupplierController.save 应提供真实功能,非 410 废弃壳(入驻申请提交端口)")
+    void ecomSupplier_save_notDeprecated410() {
+        // 入驻申请核心写入口;空参即可触达契约门(契约关注端点不被 410 阻塞)
+        Result<?> r = ecomSupplierController.save(null);
+        assertNotNull(r);
+        assertFalse(isDeprecated410(r),
+                "RED[admin.supplier.manage] /api/admin/ecom-supplier/save 应提供真实功能(SC-S-01 入驻申请提交);"
+                        + "当前返回 code=" + r.getCode() + "(410 废弃壳)。"
+                        + "TC-S-01 将此处登记为入驻申请的正式写入端点。");
+    }
+
+    @Test
+    @DisplayName("SC-S-01 异常:EcomSupplierController.update 应提供真实功能,非 410 废弃壳")
+    void ecomSupplier_update_notDeprecated410() {
+        Result<?> r = ecomSupplierController.update(null);
+        assertNotNull(r);
+        assertFalse(isDeprecated410(r),
+                "RED[admin.supplier.manage] /api/admin/ecom-supplier/update 应提供真实功能(SC-S-01);"
+                        + "当前返回 code=" + r.getCode() + "(410 废弃壳)。");
+    }
+
+    @Test
+    @DisplayName("SC-S-01 异常:EcomSupplierController.delete 应提供真实功能,非 410 废弃壳")
+    void ecomSupplier_delete_notDeprecated410() {
+        Result<?> r = ecomSupplierController.delete(Collections.emptyMap());
+        assertNotNull(r);
+        assertFalse(isDeprecated410(r),
+                "RED[admin.supplier.manage] /api/admin/ecom-supplier/delete 应提供真实功能(SC-S-01);"
+                        + "当前返回 code=" + r.getCode() + "(410 废弃壳)。");
+    }
+
+    // ==================== 边界流(SC-A-06):有在售商品的供应商停用/删除需先下架 ====================
+
+    @Test
+    @DisplayName("SC-A-06 边界:SupplyManageController.delete 对有在售商品的供应商应拒绝(当前未做前置校验)")
+    void supplyManage_delete_onSaleSupplier_rejected() {
+        // 模拟「有在售商品的供应商」:vendorType=product_supplier 且 vendorStatus=approved
+        User supplier = new User();
+        supplier.setId(1L);
+        supplier.setPhone("13800001001");
+        supplier.setVendorType("product_supplier");
+        supplier.setVendorStatus("approved");
+
+        when(userMapper.selectById(1L)).thenReturn(supplier);
+        when(userMapper.deleteById(1L)).thenReturn(1);
+
+        Map<String, Object> body = new HashMap<>();
+        body.put("id", 1L);
+        Result<Boolean> result = supplyManageController.delete(body);
+
+        // 契约要求:有在售商品的供应商删除前应先下架,否则应拒收;当前实现无条件 deleteById
+        assertTrue(result.getCode() != 200,
+                "RED[admin.supplier.manage] 有在售商品的供应商 delete 应被拒绝(SC-A-06 边界:"
+                        + "有在售商品的供应商停用需先下架);当前实现无条件执行 deleteById 返回 code="
+                        + result.getCode());
+    }
+
+    // ==================== 边界流(SC-S-01):入驻申请资质不全应驳回,不得自动通过 ====================
+
+    @Test
+    @DisplayName("SC-S-01 边界:SupplyManageController.create 无资质材料不应自动 vendorStatus=approved")
+    void supplyManage_create_notAutoApprove() {
+        // 手机号未占用
+        when(userMapper.selectOne(any())).thenReturn(null);
+        when(userMapper.insert(any())).thenAnswer(inv -> {
+            Object u = inv.getArgument(0);
+            ((User) u).setId(999L);
+            return 1;
+        });
+
+        Map<String, Object> body = new HashMap<>();
+        body.put("phone", "13800002002");
+        Result<User> result = supplyManageController.create(body);
+
+        assertNotNull(result.getData(),
+                "RED[admin.supplier.manage] create 应返回创建后的用户实体;当前 data=" + result.getData());
+        assertFalse("approved".equals(result.getData().getVendorStatus()),
+                "RED[admin.supplier.manage] 未经验核的入驻申请应保持待审状态(如 pending),"
+                        + "不得 vendorStatus=approved(SC-S-01:提交资质 → 平台审核 → 入驻成功);"
+                        + "当前实现直接 approve,违背审核流契约。");
+    }
+
+    // ==================== 边界流(SC-S-02):负价格/负库存拦截 ====================
+
+    @Test
+    @DisplayName("SC-S-02 边界:SupplierProductController.create 负库存应 400 拦截,不落库")
+    void supplierProduct_create_negativeStock_rejected() {
+        stubSupplierProductContext(makeSystem(1L, "体系A"));
+
+        Product p = new Product();
+        p.setName("负库存测试商品");
+        p.setStock(-5);
+        p.setPrice(100);
+        when(productService.adminCreate(any(), any())).thenReturn(Result.success(null));
+
+        ProductCreateRequest req = new ProductCreateRequest();
+        req.setProduct(p);
+
+        Result<?> result = supplierProductController.create(req, 1L);
+
+        assertTrue(result.getCode() == 400 || result.getCode() < 200,
+                "RED[admin.supplier.manage] 负库存商品 (-5) 创建应 400 拦截(TC-S-02-E),"
+                        + "当前透传至 adminCreate 直接落库(code=" + result.getCode() + ");"
+                        + "字段合法性校验应在 Controller 层前置拦截。");
+        verify(productService, never()).adminCreate(any(), any());
+    }
+
+    @Test
+    @DisplayName("SC-S-02 边界:SupplierProductController.create 负价格应 400 拦截,不落库")
+    void supplierProduct_create_negativePrice_rejected() {
+        stubSupplierProductContext(makeSystem(1L, "体系A"));
+
+        Product p = new Product();
+        p.setName("负价格测试商品");
+        p.setStock(10);
+        p.setPrice(-100);
+        when(productService.adminCreate(any(), any())).thenReturn(Result.success(null));
+
+        ProductCreateRequest req = new ProductCreateRequest();
+        req.setProduct(p);
+
+        Result<?> result = supplierProductController.create(req, 1L);
+
+        assertTrue(result.getCode() == 400 || result.getCode() < 200,
+                "RED[admin.supplier.manage] 负价格商品 (-100 分) 创建应 400 拦截(TC-S-02-E),"
+                        + "当前透传至 adminCreate 直接落库(code=" + result.getCode() + ");"
+                        + "字段合法性校验应在 Controller 层前置拦截。");
+        verify(productService, never()).adminCreate(any(), any());
+    }
+
+    // ==================== 权限流(TC-S-01-P / SC-A-06-P):admin 角色守卫 ====================
+
+    @Test
+    @DisplayName("TC-S-01-P/SC-A-06-P 权限:AdminSupplierController 应带 @RequestAttribute(\"role\") 守卫")
+    void adminSupplier_hasRoleGuard() {
+        assertTrue(hasRoleGuard(AdminSupplierController.class),
+                "RED[admin.supplier.manage] AdminSupplierController 方法应带"
+                        + " @RequestAttribute(\"role\") 守卫,非 admin 操作 403(SC-A-06 管理员接口约束);"
+                        + "当前方法均无 role 参数(对标 AdminAuditController/AdminArticleController 模式)。");
+    }
+
+    @Test
+    @DisplayName("TC-S-01-P/SC-A-06-P 权限:EcomSupplierController 应带 @RequestAttribute(\"role\") 守卫")
+    void ecomSupplier_hasRoleGuard() {
+        assertTrue(hasRoleGuard(EcomSupplierController.class),
+                "RED[admin.supplier.manage] EcomSupplierController 方法应带"
+                        + " @RequestAttribute(\"role\") 守卫,非 admin 操作 403(SC-S-01 / SC-A-06 管理员接口约束);"
+                        + "当前方法均无 role 参数。");
+    }
+
+    @Test
+    @DisplayName("TC-S-01-P/SC-A-06-P 权限:SupplyManageController 应带 @RequestAttribute(\"role\") 守卫")
+    void supplyManage_hasRoleGuard() {
+        assertTrue(hasRoleGuard(SupplyManageController.class),
+                "RED[admin.supplier.manage] SupplyManageController 方法应带"
+                        + " @RequestAttribute(\"role\") 守卫,非 admin 操作 403(SC-A-06 管理员接口约束);"
+                        + "当前方法均无 role 参数(对标 AdminAuditController/AdminArticleController 模式)。");
+    }
+
+    // ==================== 辅助构造 ====================
+
+    private void stubSupplierProductContext(SupplySystem system) {
+        when(supplySystemService.getByAdminId(any())).thenReturn(system);
+    }
+
+    private SupplySystem makeSystem(Long id, String name) {
+        SupplySystem s = new SupplySystem();
+        s.setId(id);
+        s.setName(name);
+        return s;
+    }
+
+    // ==================== 反射辅助 ====================
+
+    /** 控制器是否暴露指定 POST 路径端点 */
+    private boolean hasEndpoint(Class<?> controller, String path) {
+        for (java.lang.reflect.Method m : controller.getDeclaredMethods()) {
+            PostMapping pm = m.getAnnotation(PostMapping.class);
+            if (pm != null) {
+                for (String v : pm.value()) {
+                    if (path.equals(v)) {
+                        return true;
+                    }
+                }
+            }
+        }
+        return false;
+    }
+
+    /** 控制器是否含 @RequestAttribute("role") 参数守卫 */
+    private boolean hasRoleGuard(Class<?> controller) {
+        for (java.lang.reflect.Method m : controller.getDeclaredMethods()) {
+            for (java.lang.reflect.Parameter p : m.getParameters()) {
+                RequestAttribute ra = p.getAnnotation(RequestAttribute.class);
+                if (ra != null && "role".equals(ra.value())) {
+                    return true;
+                }
+            }
+        }
+        return false;
+    }
+
+    /** 结果是否为 410 废弃码 */
+    private boolean isDeprecated410(Result<?> r) {
+        return r != null && Integer.valueOf(410).equals(r.getCode());
+    }
+}