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

test(tdd): S-cfc-188 family.family.autoCreateForPurchase 契约测试(RED 4/5)

E2E Test Bot 2 часов назад
Родитель
Сommit
f284efca88

+ 243 - 0
cfc-backend/src/test/java/com/etotem/cfc/unit/FamilyAutoCreateForPurchaseContractTest.java

@@ -0,0 +1,243 @@
+package com.etotem.cfc.unit;
+
+import com.etotem.cfc.common.Result;
+import com.etotem.cfc.controller.MembershipController;
+import com.etotem.cfc.dto.PaymentOrderDTO;
+import com.etotem.cfc.entity.User;
+import com.etotem.cfc.mapper.UserMapper;
+import com.etotem.cfc.service.MembershipService;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Test;
+
+import java.lang.reflect.Field;
+import java.util.HashMap;
+import java.util.Map;
+
+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.mockito.ArgumentMatchers.eq;
+import static org.mockito.ArgumentMatchers.isNull;
+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 family.family.autoCreateForPurchase}(S-cfc-188,SC-P-58 购买会员/套餐)。
+ *
+ * <p><b>需求/溯源(tests/user-scenarios/应用场景总览.md SC-P-58 · 购买会员/套餐,业务域 F 商城与会员)</b></p>
+ * <blockquote>
+ *   目标:解锁高级能力。流程:选档位 → 支付 → 权益生效。边界:支付失败回滚;重复购买幂等。
+ *   用户实测反馈:新用户({@code familyId==null})在「我的」页/会员中心点「立即开通」后<b>下单被拦</b>
+ *   (MembershipController /upgrade 199 行 createOrder 的 familyId==null 抛错、/plan/purchase 287-299 行
+ *   noFamily 守卫),无法自助开通;期望:<b>无家庭新用户购买会员时自动创建初始家庭并完成购买</b>。
+ * </blockquote>
+ *
+ * <p><b>设计/溯源(docs/superpowers/api/API_REFERENCE.md §4.50 会员权益对比表 + §4.50.1 会员档位购买;
+ * docs/tdd/orchestration/sc-key-map.md SC-P-58)</b></p>
+ * <ul>
+ *   <li>SC-P-58 已登记 1:N 键 {@code membership.plan.purchase, family.family.autoCreateForPurchase}
+ *       (问题1「新用户无开通入口」裁决走方案A:<b>购买时自动创建家庭</b>)。</li>
+ *   <li>修复方向:MembershipController 三处 {@code getUserFamilyId} 改 {@code getOrCreateUserFamilyId}
+ *       (upgradeOrder L199 / activateTrial L224 / createOrder L245);/plan/purchase 无家庭
+ *       noFamily 守卫<b>保留兜底</b>(get-or-create 仍无法获得家庭时才拒绝)。</li>
+ *   <li>{@code MembershipService#getOrCreateUserFamilyId}(MembershipService L114-138,@Transactional)
+ *       已存在:用户无家庭时自动创建初始家庭(昵称+「的家庭」、UUID 邀请码、creator/isFamilyAdmin,
+ *       并回写 {@code user.familyId})——购买链路复用它即可与既有「微信用户被踢后迁入自有家庭」语义保持一致。</li>
+ *   <li>同类宿主先例:MindFortuneController 已在读路径直接使用 {@code getOrCreateUserFamilyId}。</li>
+ * </ul>
+ *
+ * <p><b>RED 预期</b>:上述调用点当前仍走 {@code getUserFamilyId}(新用户返回 null → /upgrade|/trial|/orders
+ * 落单被拦、/plan/purchase 被 noFamily 守卫拒绝),「自动创建初始家庭并完成购买」契约行为未落地,
+ * 以下主断言用例在当前实现应失败(红);GREEN 阶段按修复方向落地后自动转绿。</p>
+ *
+ * <p><b>测试范围</b>:纯 Mockito + 反射注入(无 Spring / 无 DB / 无真实微信);宿主
+ * {@link MembershipController} 直接实例化,私有注入点(membershipService / userMapper / testMode)
+ * 用反射注入,风格与同批 MembershipPlanPurchaseContractTest / FamilyJoinValidationRulesContractTest 一致。</p>
+ */
+class FamilyAutoCreateForPurchaseContractTest {
+
+    private static final String KEY = "[family.family.autoCreateForPurchase]";
+
+    private static final Long USER_ID = 1L;
+    /** get-or-create 为该无家庭新用户产出的初始家庭 ID */
+    private static final Long AUTO_CREATED_FAMILY_ID = 888L;
+
+    private MembershipController controller;
+    private MembershipService membershipService;
+    private UserMapper userMapper;
+
+    @BeforeEach
+    void setUp() throws Exception {
+        controller = new MembershipController();
+        membershipService = mock(MembershipService.class);
+        userMapper = mock(UserMapper.class);
+        inject(controller, "membershipService", membershipService);
+        inject(controller, "userMapper", userMapper);
+    }
+
+    // ==================== 正常流:无家庭新用户购买 → 自动创建初始家庭并完成购买 ====================
+
+    @Test
+    @DisplayName("正常流-1:/upgrade 无家庭新用户下单必须走 getOrCreateUserFamilyId 自动建家庭后落单")
+    void upgradeOrder_newUserNoFamily_mustAutoCreateFamilyBeforeCreateOrder() {
+        when(userMapper.selectById(USER_ID)).thenReturn(newUserWithoutFamily());
+        // 契约:get-or-create 为该用户产出初始家庭(自动创建并回写 user.familyId)
+        when(membershipService.getOrCreateUserFamilyId(USER_ID)).thenReturn(AUTO_CREATED_FAMILY_ID);
+        PaymentOrderDTO created = order("MO_UP_AUTO_001", AUTO_CREATED_FAMILY_ID);
+        when(membershipService.createOrder(eq(USER_ID), eq(AUTO_CREATED_FAMILY_ID),
+                eq("FAMILY"), eq("yearly"), eq("yearly"), isNull())).thenReturn(created);
+
+        Map<String, Object> params = new HashMap<>();
+        params.put("levelCode", "FAMILY");
+        params.put("paymentType", "yearly");
+        params.put("period", "yearly");
+
+        Result<PaymentOrderDTO> resp = controller.upgradeOrder(params, USER_ID);
+
+        // 契约主断言:购买必须走 get-or-create(当前走 getUserFamilyId → 新用户 familyId=null → 下单被拦)
+        verify(membershipService).getOrCreateUserFamilyId(USER_ID);
+        verify(membershipService, never()).getUserFamilyId(USER_ID);
+        verify(membershipService).createOrder(USER_ID, AUTO_CREATED_FAMILY_ID, "FAMILY", "yearly", "yearly", null);
+        assertNotNull(resp.getData(),
+                KEY + " 无家庭新用户 /upgrade 应自动创建家庭后返回待支付订单,当前下单被拦(familyId=null)。");
+        assertEquals(AUTO_CREATED_FAMILY_ID, resp.getData().getFamilyId(),
+                KEY + " 自动创建的初始家庭应作为订单归属家庭。");
+    }
+
+    @Test
+    @DisplayName("正常流-2:/trial 无家庭新用户激活试用必须走 getOrCreateUserFamilyId 自动建家庭")
+    void activateTrial_newUserNoFamily_mustAutoCreateFamily() {
+        when(userMapper.selectById(USER_ID)).thenReturn(newUserWithoutFamily());
+        when(membershipService.getOrCreateUserFamilyId(USER_ID)).thenReturn(AUTO_CREATED_FAMILY_ID);
+        PaymentOrderDTO trial = order("MO_TRIAL_AUTO_001", AUTO_CREATED_FAMILY_ID);
+        trial.setStatus("paid");
+        when(membershipService.createOrder(eq(USER_ID), eq(AUTO_CREATED_FAMILY_ID),
+                eq("FAMILY"), eq("trial"), isNull(), isNull())).thenReturn(trial);
+
+        Result<PaymentOrderDTO> resp = controller.activateTrial(USER_ID);
+
+        verify(membershipService).getOrCreateUserFamilyId(USER_ID);
+        verify(membershipService, never()).getUserFamilyId(USER_ID);
+        verify(membershipService).createOrder(USER_ID, AUTO_CREATED_FAMILY_ID, "FAMILY", "trial", null, null);
+        assertNotNull(resp.getData(),
+                KEY + " 无家庭新用户 /trial 应自动创建家庭后返回试用订单,当前被拦(familyId=null)。");
+        assertEquals(AUTO_CREATED_FAMILY_ID, resp.getData().getFamilyId(),
+                KEY + " 自动创建的初始家庭应作为试用订单归属家庭。");
+    }
+
+    @Test
+    @DisplayName("正常流-3:/orders 无家庭新用户下单必须走 getOrCreateUserFamilyId 自动建家庭后落单")
+    void createOrder_ordersEndpoint_newUserNoFamily_mustAutoCreateFamily() throws Exception {
+        setTestModeOn(controller); // 模拟支付开关:走 MembershipService.createOrder 落单路径(不触碰微信)
+        when(userMapper.selectById(USER_ID)).thenReturn(newUserWithoutFamily());
+        when(membershipService.getOrCreateUserFamilyId(USER_ID)).thenReturn(AUTO_CREATED_FAMILY_ID);
+        PaymentOrderDTO created = order("MO_ORD_AUTO_001", AUTO_CREATED_FAMILY_ID);
+        when(membershipService.createOrder(eq(USER_ID), eq(AUTO_CREATED_FAMILY_ID),
+                eq("FAMILY"), eq("yearly"), eq("yearly"), isNull())).thenReturn(created);
+
+        Map<String, Object> params = new HashMap<>();
+        params.put("levelCode", "FAMILY");
+        params.put("paymentType", "yearly");
+        params.put("period", "yearly");
+
+        Result<Object> resp = controller.createOrder(params, USER_ID);
+
+        verify(membershipService).getOrCreateUserFamilyId(USER_ID);
+        verify(membershipService).createOrder(USER_ID, AUTO_CREATED_FAMILY_ID, "FAMILY", "yearly", "yearly", null);
+        assertNotNull(resp.getData(),
+                KEY + " 无家庭新用户 /orders 应自动创建家庭后返回待支付订单,当前被拦(familyId=null)。");
+        PaymentOrderDTO data = (PaymentOrderDTO) resp.getData();
+        assertEquals(AUTO_CREATED_FAMILY_ID, data.getFamilyId(),
+                KEY + " 自动创建的初始家庭应作为订单归属家庭。");
+    }
+
+    @Test
+    @DisplayName("正常流-4:/plan/purchase 无家庭新用户购买档位必须自动建家庭后完成购买")
+    void purchasePlan_newUserNoFamily_mustAutoCreateFamilyAndComplete() {
+        when(userMapper.selectById(USER_ID)).thenReturn(newUserWithoutFamily());
+        when(membershipService.getOrCreateUserFamilyId(USER_ID)).thenReturn(AUTO_CREATED_FAMILY_ID);
+        Map<String, Object> purchaseResp = new HashMap<>();
+        purchaseResp.put("orderNo", "MO_PLAN_AUTO_001");
+        purchaseResp.put("status", "pending");
+        purchaseResp.put("levelCode", "FAMILY");
+        purchaseResp.put("payType", "wechat");
+        when(membershipService.purchasePlan(eq(USER_ID), eq(AUTO_CREATED_FAMILY_ID),
+                eq("FAMILY"), eq("yearly"), isNull())).thenReturn(purchaseResp);
+
+        Map<String, Object> params = new HashMap<>();
+        params.put("levelCode", "FAMILY");
+        params.put("period", "yearly");
+
+        Result<Object> resp = controller.purchasePlan(params, USER_ID);
+
+        // 契约主断言:当前 user.getFamilyId()==null → noFamily(5001) 守卫拒绝,未走 get-or-create(红)。
+        verify(membershipService).getOrCreateUserFamilyId(USER_ID);
+        verify(membershipService).purchasePlan(USER_ID, AUTO_CREATED_FAMILY_ID, "FAMILY", "yearly", null);
+        assertEquals(200, resp.getCode(),
+                KEY + " 无家庭新用户 /plan/purchase 应自动创建家庭后成功返回待支付订单,"
+                        + "当前被 noFamily(5001) 守卫拒绝,无法自助开通。");
+        assertNotNull(resp.getData(), KEY + " 购买成功后应返回待支付订单 + 支付参数。");
+        assertEquals("MO_PLAN_AUTO_001", ((Map<String, Object>) resp.getData()).get("orderNo"),
+                KEY + " 返回的应是自动建家庭后的待支付订单。");
+    }
+
+    // ==================== 权限流:自动建家庭失败时 noFamily 守卫兜底保留 ====================
+
+    @Test
+    @DisplayName("权限流-1:get-or-create 仍无法获得家庭时 /plan/purchase noFamily 守卫兜底拒绝(guard 兜底保留)")
+    void purchasePlan_getOrCreateStillNoFamily_guardKeptRejects() {
+        when(userMapper.selectById(USER_ID)).thenReturn(newUserWithoutFamily());
+        when(membershipService.getOrCreateUserFamilyId(USER_ID)).thenReturn(null); // get-or-create 兜底失败(异常场景)
+
+        Map<String, Object> params = new HashMap<>();
+        params.put("levelCode", "FAMILY");
+
+        Result<Object> resp = controller.purchasePlan(params, USER_ID);
+
+        assertEquals(5001, resp.getCode(),
+                KEY + " 守卫兜底:自动建家庭失败时仍应返回 noFamily(5001)(设计约束「guard 兜底保留」)。");
+        assertTrue(resp.getMessage() != null && resp.getMessage().contains("家庭"),
+                KEY + " noFamily 守卫文案应提示家庭上下文:实际 " + resp.getMessage());
+        verify(membershipService, never()).purchasePlan(USER_ID, null, "FAMILY", "yearly", null);
+    }
+
+    // ==================== 辅助构造 ====================
+
+    /** 新注册用户:无家庭(familyId==null),即「我的」页/会员中心点「立即开通」的实测用户形态 */
+    private User newUserWithoutFamily() {
+        User u = new User();
+        u.setId(USER_ID);
+        u.setFamilyId(null);
+        return u;
+    }
+
+    private static PaymentOrderDTO order(String orderNo, Long familyId) {
+        PaymentOrderDTO dto = new PaymentOrderDTO();
+        dto.setOrderNo(orderNo);
+        dto.setFamilyId(familyId);
+        dto.setLevelCode("FAMILY");
+        dto.setPaymentType("yearly");
+        dto.setAmount(39800);
+        dto.setStatus("pending");
+        dto.setPeriodNo(1);
+        dto.setTotalPeriods(1);
+        return dto;
+    }
+
+    /** 反射注入私有字段(MembershipController 无构造器注入,@Resource 字段为私有域) */
+    private static void inject(Object target, String fieldName, Object value) throws Exception {
+        Field f = target.getClass().getDeclaredField(fieldName);
+        f.setAccessible(true);
+        f.set(target, value);
+    }
+
+    /** 打开 wechat.test-mode 开关(原 @Value("${wechat.test-mode:false}") 私有 boolean 字段) */
+    private static void setTestModeOn(MembershipController c) throws Exception {
+        Field f = MembershipController.class.getDeclaredField("testMode");
+        f.setAccessible(true);
+        f.setBoolean(c, true);
+    }
+}