|
@@ -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);
|
|
|
|
|
+ }
|
|
|
|
|
+}
|