|
@@ -0,0 +1,548 @@
|
|
|
|
|
+package com.etotem.cfc.controller.admin;
|
|
|
|
|
+
|
|
|
|
|
+import com.etotem.cfc.common.Result;
|
|
|
|
|
+import com.etotem.cfc.config.JwtInterceptor;
|
|
|
|
|
+import com.etotem.cfc.dto.ProductOrderDTO;
|
|
|
|
|
+import com.etotem.cfc.entity.AfterSalesRequest;
|
|
|
|
|
+import com.etotem.cfc.service.AfterSalesService;
|
|
|
|
|
+import com.etotem.cfc.service.ProductOrderService;
|
|
|
|
|
+import org.junit.jupiter.api.BeforeEach;
|
|
|
|
|
+import org.junit.jupiter.api.DisplayName;
|
|
|
|
|
+import org.junit.jupiter.api.Test;
|
|
|
|
|
+import org.mockito.ArgumentMatchers;
|
|
|
|
|
+import org.springframework.web.bind.annotation.PostMapping;
|
|
|
|
|
+import org.springframework.web.bind.annotation.RequestAttribute;
|
|
|
|
|
+
|
|
|
|
|
+import java.lang.annotation.Annotation;
|
|
|
|
|
+import java.lang.reflect.Field;
|
|
|
|
|
+import java.lang.reflect.Method;
|
|
|
|
|
+import java.util.ArrayList;
|
|
|
|
|
+import java.util.Arrays;
|
|
|
|
|
+import java.util.Collections;
|
|
|
|
|
+import java.util.HashMap;
|
|
|
|
|
+import java.util.LinkedHashSet;
|
|
|
|
|
+import java.util.List;
|
|
|
|
|
+import java.util.Map;
|
|
|
|
|
+import java.util.Set;
|
|
|
|
|
+
|
|
|
|
|
+import static org.junit.jupiter.api.Assertions.assertEquals;
|
|
|
|
|
+import static org.junit.jupiter.api.Assertions.assertNotNull;
|
|
|
|
|
+import static org.junit.jupiter.api.Assertions.assertTrue;
|
|
|
|
|
+import static org.junit.jupiter.api.Assertions.fail;
|
|
|
|
|
+import static org.mockito.ArgumentMatchers.any;
|
|
|
|
|
+import static org.mockito.ArgumentMatchers.anyInt;
|
|
|
|
|
+import static org.mockito.ArgumentMatchers.anyLong;
|
|
|
|
|
+import static org.mockito.ArgumentMatchers.anyString;
|
|
|
|
|
+import static org.mockito.Mockito.mock;
|
|
|
|
|
+import static org.mockito.Mockito.never;
|
|
|
|
|
+import static org.mockito.Mockito.verify;
|
|
|
|
|
+import static org.mockito.Mockito.when;
|
|
|
|
|
+
|
|
|
|
|
+/**
|
|
|
|
|
+ * 契约级单测:订单/退款人工处理(能力键 {@code admin.order.manual},SC-A-12)。
|
|
|
|
|
+ *
|
|
|
|
|
+ * <p><b>溯源</b></p>
|
|
|
|
|
+ * <ul>
|
|
|
|
|
+ * <li>需求:{@code tests/user-scenarios/应用场景总览.md} SC-A-12(订单/退款人工处理——
|
|
|
|
|
+ * 查订单 → 手动退款/补发,边界:重复退款拦截、留审计)</li>
|
|
|
|
|
+ * <li>设计:{@code docs/superpowers/api/API_REFERENCE.md} §4.13(管理员接口需 {@code role=admin})与
|
|
|
|
|
+ * §4.60.12(`AdminProductOrderController` `/api/admin/product/order`:list/detail/ship/
|
|
|
|
|
+ * update-express/close/refund/approve|reject;`AdminAfterSalesController`
|
|
|
|
|
+ * `/api/admin/shop/after-sales`:list/detail/review/complete)</li>
|
|
|
|
|
+ * <li>契约补建:{@code docs/tdd/orchestration/admin-ops-kickoff-20261107.md} TR-13 /
|
|
|
|
|
+ * {@code docs/superpowers/specs/2026-11-07-admin-ops-contract.md} §2 能力键映射
|
|
|
|
|
+ * {@code admin.order.manual}(✅ 端点存在)+ §5:「补 admin 端点 MockMvc 契约 JUnit +
|
|
|
|
|
+ * <b>非 admin token → 403 权限流</b>(重点)」;任务登记 S-cfc-202
|
|
|
|
|
+ * (sc-key-map.md,RED / 未跑通)</li>
|
|
|
|
|
+ * </ul>
|
|
|
|
|
+ *
|
|
|
|
|
+ * <p><b>实现现状(origin/cfclub @ 7558d8cf4 实测)</b></p>
|
|
|
|
|
+ * <ul>
|
|
|
|
|
+ * <li>正常流/边界流已实现:两个控制器端点齐全并委托 service;service 层已含
|
|
|
|
|
+ * 重复退款拦截、售后状态守卫(仅 pending 可审核 / 仅 approved 可完成)。</li>
|
|
|
|
|
+ * <li><b>权限流未落地(本测试的红)</b>:参照 B-142-1 惯例({@code AdminArticleController#create}
|
|
|
|
|
+ * 的「无权限」守卫),管理端端点应声明 {@code @RequestAttribute("role")} 并在入口拒绝非 admin
|
|
|
|
|
+ * ({@code !"admin".equals(role) → Result.error("无权限")});当前
|
|
|
|
|
+ * {@code AdminProductOrderController}(除 list 外 6 个端点)与
|
|
|
|
|
+ * {@code AdminAfterSalesController}(全部 4 个端点)均<em>无角色守卫</em>,登录的
|
|
|
|
|
+ * parent/child/teacher 可越权发货/关单/审批退款/处理售后。框架层(JwtInterceptor
|
|
|
|
|
+ * LOGIN_REQUIRED_PATHS 已收口 {@code /api/admin/})仅保证「匿名→401」,不区分角色。</li>
|
|
|
|
|
+ * </ul>
|
|
|
|
|
+ *
|
|
|
|
|
+ * <p><b>三流划分</b></p>
|
|
|
|
|
+ * <ul>
|
|
|
|
|
+ * <li>正常流(已实现 → 绿锁定契约):端点映射齐全 + 委托 service(list/detail/ship/
|
|
|
|
|
+ * update-express/close/refund-approve/refund-reject;售后 list/detail/review/complete)。</li>
|
|
|
|
|
+ * <li>边界流(已实现 → 绿锁定契约):售后 review 状态守卫(非 pending 拒绝、无效 action 拒绝)、
|
|
|
|
|
+ * complete 状态守卫(仅 approved 可完成);订单重复退款拦截既有契约覆盖见
|
|
|
|
|
+ * {@code OrderRefundScP56ScA12ContractTest}。</li>
|
|
|
|
|
+ * <li>权限流(契约缺口 → <b>预期红</b>):订单 6 端点 + 售后 4 端点缺 admin 角色守卫;
|
|
|
|
|
+ * list 非 admin(roles=[parent])应 403 且不委托;refund/approve、售后 review
|
|
|
|
|
+ * 非 admin 应「无权限」且不委托。</li>
|
|
|
|
|
+ * </ul>
|
|
|
|
|
+ *
|
|
|
|
|
+ * <p><b>RED 预期</b>:权限流 5 例失败(红),断言原文含「RED[admin.order.manual]」,失败原因可贴
|
|
|
|
|
+ * (角色守卫参数缺失 / list 被非 admin 放行 / refund/approve 与 review 被非 admin 执行)。</p>
|
|
|
|
|
+ *
|
|
|
|
|
+ * <p><b>GREEN 升级指引</b>:按 B-142-1 惯例为上述 10 个端点增加
|
|
|
|
|
+ * {@code @RequestAttribute("role") String role}(list 保留 {@code @RequestAttribute("roles")}
|
|
|
|
|
+ * 并放行平台角色/供应商),入口 {@code !"admin".equals(role) → Result.error("无权限")};
|
|
|
|
|
+ * 实现后本测试自动转绿。</p>
|
|
|
|
|
+ *
|
|
|
|
|
+ * <p><b>测试范围</b>:纯契约级单测(Mockito + 反射),无 Spring 上下文 / 无 DB / 无外部服务。</p>
|
|
|
|
|
+ */
|
|
|
|
|
+class AdminOrderManualContractTest {
|
|
|
|
|
+
|
|
|
|
|
+ /** 订单人工处理全端点(相对 {@code /api/admin/product/order}) */
|
|
|
|
|
+ private static final List<String> ORDER_ENDPOINTS = Arrays.asList(
|
|
|
|
|
+ "list", "detail", "ship", "update-express", "close", "refund/approve", "refund/reject");
|
|
|
|
|
+
|
|
|
|
|
+ /** 订单端点上应声明角色守卫参数的端点(list 已有 @RequestAttribute("roles"),属既有载体) */
|
|
|
|
|
+ private static final List<String> ORDER_ENDPOINTS_NEED_ROLE_GUARD = Arrays.asList(
|
|
|
|
|
+ "detail", "ship", "update-express", "close", "refund/approve", "refund/reject");
|
|
|
|
|
+
|
|
|
|
|
+ /** 售后人工处理全端点(相对 {@code /api/admin/shop/after-sales}) */
|
|
|
|
|
+ private static final List<String> AFTER_SALES_ENDPOINTS = Arrays.asList(
|
|
|
|
|
+ "list", "detail", "review", "complete");
|
|
|
|
|
+
|
|
|
|
|
+ private AdminProductOrderController orderController;
|
|
|
|
|
+ private AdminAfterSalesController afterSalesController;
|
|
|
|
|
+ private ProductOrderService orderService;
|
|
|
|
|
+ private AfterSalesService afterSalesService;
|
|
|
|
|
+
|
|
|
|
|
+ /** 边界流:真实 AfterSalesService + mock mapper(售后状态守卫绿锚点) */
|
|
|
|
|
+ private AfterSalesService realAfterSalesService;
|
|
|
|
|
+ private com.etotem.cfc.mapper.AfterSalesRequestMapper afterSalesRequestMapper;
|
|
|
|
|
+
|
|
|
|
|
+ @BeforeEach
|
|
|
|
|
+ void setUp() throws Exception {
|
|
|
|
|
+ orderController = new AdminProductOrderController();
|
|
|
|
|
+ afterSalesController = new AdminAfterSalesController();
|
|
|
|
|
+ orderService = mock(ProductOrderService.class);
|
|
|
|
|
+ afterSalesService = mock(AfterSalesService.class);
|
|
|
|
|
+ setField(orderController, "orderService", orderService);
|
|
|
|
|
+ setField(afterSalesController, "afterSalesService", afterSalesService);
|
|
|
|
|
+
|
|
|
|
|
+ realAfterSalesService = new AfterSalesService();
|
|
|
|
|
+ afterSalesRequestMapper = mock(com.etotem.cfc.mapper.AfterSalesRequestMapper.class);
|
|
|
|
|
+ setField(realAfterSalesService, "afterSalesRequestMapper", afterSalesRequestMapper);
|
|
|
|
|
+ setField(realAfterSalesService, "productOrderMapper",
|
|
|
|
|
+ mock(com.etotem.cfc.mapper.ProductOrderMapper.class));
|
|
|
|
|
+
|
|
|
|
|
+ // 正常流委托桩:各 service 方法返回成功
|
|
|
|
|
+ // 注意:list 的 keyword/buyerKeyword/startDate/endDate 可为 null,
|
|
|
|
|
+ // 桩须用 any()(可匹配 null)而非 anyString(),否则返回 null → 断言 NPE
|
|
|
|
|
+ when(orderService.adminOrderPage(anyInt(), anyInt(), any(), any(),
|
|
|
|
|
+ any(), any(), any(), any(), any()))
|
|
|
|
|
+ .thenReturn(Result.success(new HashMap<String, Object>()));
|
|
|
|
|
+ when(orderService.adminOrderDetail(anyString()))
|
|
|
|
|
+ .thenReturn(Result.success(new ProductOrderDTO()));
|
|
|
|
|
+ when(orderService.ship(anyString(), anyString(), anyString(), anyString(), anyInt(), anyString()))
|
|
|
|
|
+ .thenReturn(Result.success("已发货"));
|
|
|
|
|
+ when(orderService.updateExpress(anyString(), anyString(), anyString(), anyInt()))
|
|
|
|
|
+ .thenReturn(Result.success("快递信息已更新"));
|
|
|
|
|
+ when(orderService.adminClose(anyString(), anyLong()))
|
|
|
|
|
+ .thenReturn(Result.success("订单已关闭"));
|
|
|
|
|
+ when(orderService.approveRefund(anyString(), anyLong()))
|
|
|
|
|
+ .thenReturn(Result.success("退款已处理"));
|
|
|
|
|
+ when(orderService.rejectRefund(anyString(), anyLong(), anyString()))
|
|
|
|
|
+ .thenReturn(Result.success("已拒绝退款申请"));
|
|
|
|
|
+ when(afterSalesService.adminList(any()))
|
|
|
|
|
+ .thenReturn(Result.success(new ArrayList<AfterSalesRequest>()));
|
|
|
|
|
+ when(afterSalesService.adminDetail(any()))
|
|
|
|
|
+ .thenReturn(Result.success(new AfterSalesRequest()));
|
|
|
|
|
+ when(afterSalesService.review(any(), any(), any(), anyLong()))
|
|
|
|
|
+ .thenReturn(Result.success("操作成功"));
|
|
|
|
|
+ when(afterSalesService.complete(any()))
|
|
|
|
|
+ .thenReturn(Result.success("已完成"));
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ // ==================== 正常流:端点映射齐全(绿锚点) ====================
|
|
|
|
|
+
|
|
|
|
|
+ @Test
|
|
|
|
|
+ @DisplayName("正常流-1:订单人工处理 7 端点映射齐全(list/detail/ship/update-express/close/refund/approve|reject)")
|
|
|
|
|
+ void 正常流_订单端点映射齐全() {
|
|
|
|
|
+ Set<String> paths = endpointPaths(AdminProductOrderController.class);
|
|
|
|
|
+ assertTrue(paths.containsAll(ORDER_ENDPOINTS),
|
|
|
|
|
+ "[admin.order.manual] AdminProductOrderController 应提供契约端点映射:"
|
|
|
|
|
+ + ORDER_ENDPOINTS + ",实际:" + paths);
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ @Test
|
|
|
|
|
+ @DisplayName("正常流-2:售后人工处理 4 端点映射齐全(list/detail/review/complete)")
|
|
|
|
|
+ void 正常流_售后端点映射齐全() {
|
|
|
|
|
+ Set<String> paths = endpointPaths(AdminAfterSalesController.class);
|
|
|
|
|
+ assertTrue(paths.containsAll(AFTER_SALES_ENDPOINTS),
|
|
|
|
|
+ "[admin.order.manual] AdminAfterSalesController 应提供契约端点映射:"
|
|
|
|
|
+ + AFTER_SALES_ENDPOINTS + ",实际:" + paths);
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ // ==================== 正常流:订单端点委托 service(绿锚点) ====================
|
|
|
|
|
+
|
|
|
|
|
+ @Test
|
|
|
|
|
+ @DisplayName("正常流-3:order/list 委托 orderService.adminOrderPage(分页/筛选/供应商行级隔离参数)")
|
|
|
|
|
+ void 正常流_订单list_委托adminOrderPage() {
|
|
|
|
|
+ Map<String, Object> body = new HashMap<>();
|
|
|
|
|
+ body.put("status", "paid");
|
|
|
|
|
+ body.put("page", 1);
|
|
|
|
|
+ body.put("size", 20);
|
|
|
|
|
+ Result<Map<String, Object>> r =
|
|
|
|
|
+ orderController.list(body, 1L, Collections.singletonList("admin"));
|
|
|
|
|
+ assertEquals(200, r.getCode());
|
|
|
|
|
+ // 注意:keyword/buyerKeyword/startDate/endDate 为 null,verify 须用 any()(可匹配 null)
|
|
|
|
|
+ verify(orderService).adminOrderPage(anyInt(), anyInt(), any(), any(),
|
|
|
|
|
+ any(), any(), any(), any(), any());
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ @Test
|
|
|
|
|
+ @DisplayName("正常流-4:order/detail 委托 orderService.adminOrderDetail(orderNo)")
|
|
|
|
|
+ void 正常流_订单detail_委托adminOrderDetail() {
|
|
|
|
|
+ Map<String, Object> body = new HashMap<>();
|
|
|
|
|
+ body.put("orderNo", "PO1");
|
|
|
|
|
+ Result<ProductOrderDTO> r = orderController.detail(body);
|
|
|
|
|
+ assertEquals(200, r.getCode());
|
|
|
|
|
+ verify(orderService).adminOrderDetail("PO1");
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ @Test
|
|
|
|
|
+ @DisplayName("正常流-5:order/ship 委托 orderService.ship(发货方式/单号/公司/快递费/联系人六参)")
|
|
|
|
|
+ void 正常流_订单ship_委托ship() {
|
|
|
|
|
+ Map<String, Object> body = new HashMap<>();
|
|
|
|
|
+ body.put("orderNo", "PO1");
|
|
|
|
|
+ body.put("shipType", "express");
|
|
|
|
|
+ body.put("trackingNumber", "SF123456");
|
|
|
|
|
+ body.put("expressCompany", "顺丰速运");
|
|
|
|
|
+ body.put("shippingCost", 1000);
|
|
|
|
|
+ body.put("receiverContact", "13800000000");
|
|
|
|
|
+ Result<String> r = orderController.ship(body, 1L);
|
|
|
|
|
+ assertEquals(200, r.getCode());
|
|
|
|
|
+ verify(orderService).ship("PO1", "express", "SF123456", "顺丰速运", 1000, "13800000000");
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ @Test
|
|
|
|
|
+ @DisplayName("正常流-6:order/update-express 委托 orderService.updateExpress")
|
|
|
|
|
+ void 正常流_订单updateExpress_委托() {
|
|
|
|
|
+ Map<String, Object> body = new HashMap<>();
|
|
|
|
|
+ body.put("orderNo", "PO1");
|
|
|
|
|
+ body.put("trackingNumber", "YT111");
|
|
|
|
|
+ body.put("expressCompany", "圆通速递");
|
|
|
|
|
+ body.put("shippingCost", 800);
|
|
|
|
|
+ Result<String> r = orderController.updateExpress(body);
|
|
|
|
|
+ assertEquals(200, r.getCode());
|
|
|
|
|
+ verify(orderService).updateExpress("PO1", "YT111", "圆通速递", 800);
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ @Test
|
|
|
|
|
+ @DisplayName("正常流-7:order/close 委托 orderService.adminClose(orderNo, adminId)")
|
|
|
|
|
+ void 正常流_订单close_委托adminClose() {
|
|
|
|
|
+ Map<String, Object> body = new HashMap<>();
|
|
|
|
|
+ body.put("orderNo", "PO9");
|
|
|
|
|
+ Result<String> r = orderController.close(body, 1L);
|
|
|
|
|
+ assertEquals(200, r.getCode());
|
|
|
|
|
+ verify(orderService).adminClose("PO9", 1L);
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ @Test
|
|
|
|
|
+ @DisplayName("正常流-8:order/refund/approve 委托 orderService.approveRefund")
|
|
|
|
|
+ void 正常流_退款approve_委托() {
|
|
|
|
|
+ Map<String, Object> body = new HashMap<>();
|
|
|
|
|
+ body.put("orderNo", "PO7");
|
|
|
|
|
+ Result<String> r = orderController.approveRefund(body, 1L);
|
|
|
|
|
+ assertEquals(200, r.getCode());
|
|
|
|
|
+ verify(orderService).approveRefund("PO7", 1L);
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ @Test
|
|
|
|
|
+ @DisplayName("正常流-9:order/refund/reject 委托 orderService.rejectRefund")
|
|
|
|
|
+ void 正常流_退款reject_委托() {
|
|
|
|
|
+ Map<String, Object> body = new HashMap<>();
|
|
|
|
|
+ body.put("orderNo", "PO7");
|
|
|
|
|
+ body.put("reason", "证据不足");
|
|
|
|
|
+ Result<String> r = orderController.rejectRefund(body, 1L);
|
|
|
|
|
+ assertEquals(200, r.getCode());
|
|
|
|
|
+ verify(orderService).rejectRefund("PO7", 1L, "证据不足");
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ // ==================== 正常流:售后端点委托 service(绿锚点) ====================
|
|
|
|
|
+
|
|
|
|
|
+ @Test
|
|
|
|
|
+ @DisplayName("正常流-10:after-sales/list 委托 afterSalesService.adminList(status)")
|
|
|
|
|
+ void 正常流_售后list_委托adminList() {
|
|
|
|
|
+ Map<String, Object> body = new HashMap<>();
|
|
|
|
|
+ body.put("status", "pending");
|
|
|
|
|
+ Result<List<AfterSalesRequest>> r = afterSalesController.list(body);
|
|
|
|
|
+ assertEquals(200, r.getCode());
|
|
|
|
|
+ verify(afterSalesService).adminList("pending");
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ @Test
|
|
|
|
|
+ @DisplayName("正常流-11:after-sales/detail 委托 afterSalesService.adminDetail(id)")
|
|
|
|
|
+ void 正常流_售后detail_委托adminDetail() {
|
|
|
|
|
+ Map<String, Object> body = new HashMap<>();
|
|
|
|
|
+ body.put("id", 1L);
|
|
|
|
|
+ Result<AfterSalesRequest> r = afterSalesController.detail(body);
|
|
|
|
|
+ assertEquals(200, r.getCode());
|
|
|
|
|
+ verify(afterSalesService).adminDetail(1L);
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ @Test
|
|
|
|
|
+ @DisplayName("正常流-12:after-sales/review 委托 afterSalesService.review(approve/reject)")
|
|
|
|
|
+ void 正常流_售后review_委托() {
|
|
|
|
|
+ Map<String, Object> body = new HashMap<>();
|
|
|
|
|
+ body.put("id", 1L);
|
|
|
|
|
+ body.put("action", "approve");
|
|
|
|
|
+ Result<String> r = afterSalesController.review(body, 1L);
|
|
|
|
|
+ assertEquals(200, r.getCode());
|
|
|
|
|
+ verify(afterSalesService).review(1L, "approve", null, 1L);
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ @Test
|
|
|
|
|
+ @DisplayName("正常流-13:after-sales/complete 委托 afterSalesService.complete(id)")
|
|
|
|
|
+ void 正常流_售后complete_委托() {
|
|
|
|
|
+ Map<String, Object> body = new HashMap<>();
|
|
|
|
|
+ body.put("id", 1L);
|
|
|
|
|
+ Result<String> r = afterSalesController.complete(body);
|
|
|
|
|
+ assertEquals(200, r.getCode());
|
|
|
|
|
+ verify(afterSalesService).complete(1L);
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ // ==================== 边界流:售后状态守卫已实现(绿锚点) ====================
|
|
|
|
|
+
|
|
|
|
|
+ @Test
|
|
|
|
|
+ @DisplayName("边界流-1:售后 review 仅待审核(pending)可处理——已批准申请再次审核被拒且不落库")
|
|
|
|
|
+ void 边界流_售后review_非pending拒绝() {
|
|
|
|
|
+ when(afterSalesRequestMapper.selectById(1L)).thenReturn(requestWithStatus("approved"));
|
|
|
|
|
+ Result<String> r = realAfterSalesService.review(1L, "approve", null, 9L);
|
|
|
|
|
+ assertEquals("仅待审核的申请可处理", r.getMessage());
|
|
|
|
|
+ verify(afterSalesRequestMapper, never()).updateById(any(AfterSalesRequest.class));
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ @Test
|
|
|
|
|
+ @DisplayName("边界流-2:售后 review 无效 action 被拒且不落库")
|
|
|
|
|
+ void 边界流_售后review_无效action拒绝() {
|
|
|
|
|
+ when(afterSalesRequestMapper.selectById(1L)).thenReturn(requestWithStatus("pending"));
|
|
|
|
|
+ Result<String> r = realAfterSalesService.review(1L, "bogus", null, 9L);
|
|
|
|
|
+ assertEquals("无效操作", r.getMessage());
|
|
|
|
|
+ verify(afterSalesRequestMapper, never()).updateById(any(AfterSalesRequest.class));
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ @Test
|
|
|
|
|
+ @DisplayName("边界流-3:售后 review 申请不存在 → 拒绝")
|
|
|
|
|
+ void 边界流_售后review_申请不存在() {
|
|
|
|
|
+ when(afterSalesRequestMapper.selectById(1L)).thenReturn(null);
|
|
|
|
|
+ Result<String> r = realAfterSalesService.review(1L, "approve", null, 9L);
|
|
|
|
|
+ assertEquals("售后申请不存在", r.getMessage());
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ @Test
|
|
|
|
|
+ @DisplayName("边界流-4:售后 complete 仅已批准可完成——pending 拒绝且不落库")
|
|
|
|
|
+ void 边界流_售后complete_非approved拒绝() {
|
|
|
|
|
+ when(afterSalesRequestMapper.selectById(1L)).thenReturn(requestWithStatus("pending"));
|
|
|
|
|
+ Result<String> r = realAfterSalesService.complete(1L);
|
|
|
|
|
+ assertEquals("仅已批准的申请可完成", r.getMessage());
|
|
|
|
|
+ verify(afterSalesRequestMapper, never()).updateById(any(AfterSalesRequest.class));
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ @Test
|
|
|
|
|
+ @DisplayName("边界流-5:售后 complete 对 approved 申请成功闭环(approved→completed)")
|
|
|
|
|
+ void 边界流_售后complete_approved闭环成功() {
|
|
|
|
|
+ AfterSalesRequest request = requestWithStatus("approved");
|
|
|
|
|
+ when(afterSalesRequestMapper.selectById(1L)).thenReturn(request);
|
|
|
|
|
+ Result<String> r = realAfterSalesService.complete(1L);
|
|
|
|
|
+ assertEquals(200, r.getCode());
|
|
|
|
|
+ assertEquals("已完成", r.getData());
|
|
|
|
|
+ assertEquals("completed", request.getStatus());
|
|
|
|
|
+ verify(afterSalesRequestMapper).updateById(request);
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ // ==================== 权限流(契约缺口,预期红 ×5,B-142-1 / TR-13) ====================
|
|
|
|
|
+
|
|
|
|
|
+ @Test
|
|
|
|
|
+ @DisplayName("权限流-框架层:JwtInterceptor.LOGIN_REQUIRED_PATHS 已收口 /api/admin/(匿名→401,框架层已落地)")
|
|
|
|
|
+ void 权限流_框架层_登录门已收口apiAdmin() throws Exception {
|
|
|
|
|
+ Field f = JwtInterceptor.class.getDeclaredField("LOGIN_REQUIRED_PATHS");
|
|
|
|
|
+ f.setAccessible(true);
|
|
|
|
|
+ String[] paths = (String[]) f.get(null);
|
|
|
|
|
+ boolean covered = false;
|
|
|
|
|
+ for (String p : paths) {
|
|
|
|
|
+ if ("/api/admin/".equals(p)) {
|
|
|
|
|
+ covered = true;
|
|
|
|
|
+ break;
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ assertTrue(covered,
|
|
|
|
|
+ "[admin.order.manual] JwtInterceptor.LOGIN_REQUIRED_PATHS 应含 \"/api/admin/\","
|
|
|
|
|
+ + "使匿名请求 admin 订单/售后端点 → 401(B-142-1 框架层;本契约关注的是框架层之下"
|
|
|
|
|
+ + " controller 角色的 403 守卫,见下方 RED 用例)。");
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ @Test
|
|
|
|
|
+ @DisplayName("权限流-R1:订单人工处理 6 端点应声明 admin 角色守卫参数(缺守卫 → 红)")
|
|
|
|
|
+ void 权限流_订单端点_缺角色守卫() {
|
|
|
|
|
+ List<String> missing = new ArrayList<>();
|
|
|
|
|
+ for (String ep : ORDER_ENDPOINTS_NEED_ROLE_GUARD) {
|
|
|
|
|
+ if (!hasRoleGuardParam(AdminProductOrderController.class, methodOf(ep))) {
|
|
|
|
|
+ missing.add(ep);
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ assertTrue(missing.isEmpty(),
|
|
|
|
|
+ "RED[admin.order.manual] 订单人工处理端点应声明角色守卫参数"
|
|
|
|
|
+ + "(@RequestAttribute(\"role\")/@RequestAttribute(\"roles\"),B-142-1 惯例,"
|
|
|
|
|
+ + "参照 AdminArticleController#create 的「无权限」守卫),非 admin(parent/child/teacher)"
|
|
|
|
|
+ + "应被拒绝且不触达 service;当前缺守卫的端点:" + missing
|
|
|
|
|
+ + "。GREEN:增加 @RequestAttribute(\"role\") String role 并在入口"
|
|
|
|
|
+ + " !\"admin\".equals(role) → Result.error(\"无权限\")。");
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ @Test
|
|
|
|
|
+ @DisplayName("权限流-R2:售后人工处理 4 端点应声明 admin 角色守卫参数(缺守卫 → 红)")
|
|
|
|
|
+ void 权限流_售后端点_缺角色守卫() {
|
|
|
|
|
+ List<String> missing = new ArrayList<>();
|
|
|
|
|
+ for (String ep : AFTER_SALES_ENDPOINTS) {
|
|
|
|
|
+ if (!hasRoleGuardParam(AdminAfterSalesController.class, ep)) {
|
|
|
|
|
+ missing.add(ep);
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ assertTrue(missing.isEmpty(),
|
|
|
|
|
+ "RED[admin.order.manual] 售后人工处理端点应声明角色守卫参数"
|
|
|
|
|
+ + "(@RequestAttribute(\"role\"),B-142-1 惯例),非 admin(parent/child/teacher)"
|
|
|
|
|
+ + "应被拒绝且不触达 service;当前缺守卫的端点:" + missing
|
|
|
|
|
+ + "。GREEN:增加 @RequestAttribute(\"role\") String role 并在入口"
|
|
|
|
|
+ + " !\"admin\".equals(role) → Result.error(\"无权限\")。");
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ @Test
|
|
|
|
|
+ @DisplayName("权限流-R3:order/list 非 admin 角色(roles=[parent])应 403 拒绝且不委托(→ 红)")
|
|
|
|
|
+ void 权限流_订单list_非admin应403拒绝() {
|
|
|
|
|
+ Map<String, Object> body = new HashMap<>();
|
|
|
|
|
+ body.put("status", "paid");
|
|
|
|
|
+ Result<Map<String, Object>> r =
|
|
|
|
|
+ orderController.list(body, 1L, Collections.singletonList("parent"));
|
|
|
|
|
+ assertEquals(403, r.getCode(),
|
|
|
|
|
+ "RED[admin.order.manual] order/list 由非 admin 角色(roles=[parent],无供应商/平台角色)"
|
|
|
|
|
+ + "调用时应返回 403 无权访问,不得委托 orderService.adminOrderPage 泄漏全量订单;"
|
|
|
|
|
+ + "当前被放行(B-142-1 角色守卫缺失,TR-13 权限流)。");
|
|
|
|
|
+ verify(orderService, never()).adminOrderPage(anyInt(), anyInt(), any(), any(),
|
|
|
|
|
+ any(), any(), any(), any(), any());
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ @Test
|
|
|
|
|
+ @DisplayName("权限流-R4:order/refund/approve 非 admin 角色应「无权限」拒绝且不委托(→ 红)")
|
|
|
|
|
+ void 权限流_退款approve_非admin拒绝() throws Exception {
|
|
|
|
|
+ Map<String, Object> body = new HashMap<>();
|
|
|
|
|
+ body.put("orderNo", "PO7");
|
|
|
|
|
+ Result<?> r = invokeWithRole(orderController, "approveRefund", body, 1L, "parent");
|
|
|
|
|
+ assertNotNull(r, "RED[admin.order.manual] refund/approve 应返回无权结果而非 null。");
|
|
|
|
|
+ assertEquals("无权限", r.getMessage(),
|
|
|
|
|
+ "RED[admin.order.manual] refund/approve 由非 admin 角色调用时应按 B-142-1 惯例返回"
|
|
|
|
|
+ + "「无权限」;当前方法无角色守卫参数(@RequestAttribute(\"role\")),parent/child"
|
|
|
|
|
+ + "可直接审批退款(TR-13 权限流)。GREEN:增加 role 参数并在入口守卫。");
|
|
|
|
|
+ verify(orderService, never()).approveRefund(anyString(), anyLong());
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ @Test
|
|
|
|
|
+ @DisplayName("权限流-R5:after-sales/review 非 admin 角色应「无权限」拒绝且不委托(→ 红)")
|
|
|
|
|
+ void 权限流_售后review_非admin拒绝() throws Exception {
|
|
|
|
|
+ Map<String, Object> body = new HashMap<>();
|
|
|
|
|
+ body.put("id", 1L);
|
|
|
|
|
+ body.put("action", "approve");
|
|
|
|
|
+ Result<?> r = invokeWithRole(afterSalesController, "review", body, 1L, "parent");
|
|
|
|
|
+ assertNotNull(r, "RED[admin.order.manual] 售后 review 应返回无权结果而非 null。");
|
|
|
|
|
+ assertEquals("无权限", r.getMessage(),
|
|
|
|
|
+ "RED[admin.order.manual] 售后 review 由非 admin 角色调用时应按 B-142-1 惯例返回"
|
|
|
|
|
+ + "「无权限」;当前方法无角色守卫参数(@RequestAttribute(\"role\")),parent/child"
|
|
|
|
|
+ + "可直接处理售后单(TR-13 权限流)。GREEN:增加 role 参数并在入口守卫。");
|
|
|
|
|
+ verify(afterSalesService, never()).review(anyLong(), any(), any(), anyLong());
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ // ==================== 辅助 ====================
|
|
|
|
|
+
|
|
|
|
|
+ private static void setField(Object target, String name, Object value) throws Exception {
|
|
|
|
|
+ Field f = target.getClass().getDeclaredField(name);
|
|
|
|
|
+ f.setAccessible(true);
|
|
|
|
|
+ f.set(target, value);
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ /** 采集类上所有 @PostMapping 方法映射的相对路径(归一化:去掉前导斜杠) */
|
|
|
|
|
+ private static Set<String> endpointPaths(Class<?> clazz) {
|
|
|
|
|
+ Set<String> paths = new LinkedHashSet<>();
|
|
|
|
|
+ for (Method m : clazz.getDeclaredMethods()) {
|
|
|
|
|
+ PostMapping pm = m.getAnnotation(PostMapping.class);
|
|
|
|
|
+ if (pm != null) {
|
|
|
|
|
+ for (String p : pm.value()) {
|
|
|
|
|
+ paths.add(p.replaceFirst("^/+", ""));
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ return paths;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ /** 该方法是否声明了 admin 角色承载参数(@RequestAttribute value = "role" 或 "roles") */
|
|
|
|
|
+ private static boolean hasRoleGuardParam(Class<?> clazz, String methodName) {
|
|
|
|
|
+ for (Method m : clazz.getDeclaredMethods()) {
|
|
|
|
|
+ if (!m.getName().equals(methodName)) {
|
|
|
|
|
+ continue;
|
|
|
|
|
+ }
|
|
|
|
|
+ for (Annotation[] anns : m.getParameterAnnotations()) {
|
|
|
|
|
+ for (Annotation a : anns) {
|
|
|
|
|
+ if (a instanceof RequestAttribute) {
|
|
|
|
|
+ String v = ((RequestAttribute) a).value();
|
|
|
|
|
+ if ("role".equals(v) || "roles".equals(v)) {
|
|
|
|
|
+ return true;
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ return false;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ /**
|
|
|
|
|
+ * 反射派发任一 admin 端点方法并携带角色:兼容当前签名(RED 现状,如 refund/approve 为
|
|
|
|
|
+ * {@code (Map, Long)}、review 为 {@code (Map, Long)})与契约签名(GREEN 后为
|
|
|
|
|
+ * {@code (Map, Long, String role)}),避免 GREEN 阶段改签名导致本测试编译失败。
|
|
|
|
|
+ */
|
|
|
|
|
+ private static Result<?> invokeWithRole(Object controller, String methodName,
|
|
|
|
|
+ Map<String, Object> body, Long adminId, String role)
|
|
|
|
|
+ throws Exception {
|
|
|
|
|
+ for (Method m : controller.getClass().getDeclaredMethods()) {
|
|
|
|
|
+ if (!m.getName().equals(methodName)) {
|
|
|
|
|
+ continue;
|
|
|
|
|
+ }
|
|
|
|
|
+ Class<?>[] pts = m.getParameterTypes();
|
|
|
|
|
+ m.setAccessible(true);
|
|
|
|
|
+ if (pts.length == 3 && pts[0] == Map.class && pts[1] == Long.class
|
|
|
|
|
+ && pts[2] == String.class) {
|
|
|
|
|
+ return (Result<?>) m.invoke(controller, body, adminId, role);
|
|
|
|
|
+ }
|
|
|
|
|
+ if (pts.length == 2 && pts[0] == Map.class && pts[1] == Long.class) {
|
|
|
|
|
+ return (Result<?>) m.invoke(controller, body, adminId);
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ fail("契约方法不存在: " + methodName);
|
|
|
|
|
+ return null;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ /** 端点路径 → 控制器方法名(仅非同名路径需要映射) */
|
|
|
|
|
+ private static String methodOf(String endpoint) {
|
|
|
|
|
+ if ("refund/approve".equals(endpoint)) {
|
|
|
|
|
+ return "approveRefund";
|
|
|
|
|
+ }
|
|
|
|
|
+ if ("refund/reject".equals(endpoint)) {
|
|
|
|
|
+ return "rejectRefund";
|
|
|
|
|
+ }
|
|
|
|
|
+ if ("update-express".equals(endpoint)) {
|
|
|
|
|
+ return "updateExpress";
|
|
|
|
|
+ }
|
|
|
|
|
+ return endpoint;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ private static AfterSalesRequest requestWithStatus(String status) {
|
|
|
|
|
+ AfterSalesRequest r = new AfterSalesRequest();
|
|
|
|
|
+ r.setId(1L);
|
|
|
|
|
+ r.setOrderNo("PO1");
|
|
|
|
|
+ r.setUserId(7L);
|
|
|
|
|
+ r.setStatus(status);
|
|
|
|
|
+ return r;
|
|
|
|
|
+ }
|
|
|
|
|
+}
|