Browse Source

Merge branch 'task/S-cfc-214' into cfclub

zxyj-langgraph 2 hours ago
parent
commit
edb8eddf7b

+ 423 - 0
cfc-backend/src/test/java/com/etotem/cfc/unit/ChildWishManageContractTest.java

@@ -0,0 +1,423 @@
+package com.etotem.cfc.unit;
+
+import com.etotem.cfc.common.Result;
+import com.etotem.cfc.controller.EnergyController;
+import com.etotem.cfc.controller.wish.WishController;
+import com.etotem.cfc.dto.CreateWishDTO;
+import com.etotem.cfc.entity.EnergyBalance;
+import com.etotem.cfc.entity.EnergyDimension;
+import com.etotem.cfc.entity.EnergyLog;
+import com.etotem.cfc.entity.FamilyMember;
+import com.etotem.cfc.entity.User;
+import com.etotem.cfc.entity.Wish;
+import com.etotem.cfc.mapper.EnergyBalanceMapper;
+import com.etotem.cfc.mapper.EnergyDimensionMapper;
+import com.etotem.cfc.mapper.EnergyLogMapper;
+import com.etotem.cfc.mapper.FamilyMemberMapper;
+import com.etotem.cfc.mapper.UserMapper;
+import com.etotem.cfc.mapper.WishMapper;
+import com.etotem.cfc.service.CfSpendDispatchService;
+import com.etotem.cfc.service.EnergyService;
+import com.etotem.cfc.service.NotificationDispatchService;
+import com.etotem.cfc.service.WishService;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Test;
+import org.springframework.web.bind.annotation.PostMapping;
+import org.springframework.web.bind.annotation.RequestMapping;
+
+import java.lang.reflect.Method;
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.Collections;
+import java.util.HashMap;
+import java.util.HashSet;
+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.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertNotNull;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+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.Mockito.mock;
+import static org.mockito.Mockito.never;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+
+/**
+ * 契约级单测:能力键 {@code child.wish.manage}(SC-C-04 提心愿)。
+ *
+ * <p><b>溯源·需求</b>:{@code tests/user-scenarios/应用场景总览.md} SC-C-04(提心愿)——
+ * 建心愿(名称+积分)→ 进家长待审批;边界:积分超额提示;被驳回不可兑。</p>
+ *
+ * <p><b>溯源·设计</b>:API_REFERENCE §4.4 心愿(/api/wishes):create / set-price / reject /
+ * exchange / approve-exchange / fulfill(任务书引 §4.6,实际心愿章节在 §4.4,已按实锚定);
+ * EnergyController /deduct-for-wish(积分扣减)。</p>
+ *
+ * <p><b>承担端点</b>:{@link WishController} /api/wishes(create/set-price/reject/exchange/
+ * approve-exchange/fulfill)+ {@link EnergyController} /deduct-for-wish。</p>
+ *
+ * <p><b>实测结论(2026-11-13)</b>:本能力<b>实现已落地</b> —— 6 个契约端点、WishService 状态机
+ * (draft→active→pending_exchange→approved→exchanging→fulfilled/cancelled/rejected)、
+ * EnergyService.deductForWish(扣 action 维度)与 CfSpendDispatchService.spendForMember
+ * (refType=wish_exchange)均已在 origin/cfclub 存在,且已被 {@code WishFlowScP13ContractTest}
+ * (SC-C-04/SC-P-13/14/88,15 用例全绿)等既有测试覆盖。按任务交付纪律(任务要求 #2:
+ * 「若实测发现实现已存在(测试意外全绿),在汇报中注明并交编排侧转测试登记,不私改实现」),
+ * 本类作为「契约登记验证」新增:预期 <b>全绿(非红)</b>,供编排侧转测试登记;
+ * 不修改 src/main 实现。</p>
+ *
+ * <p><b>测试范围</b>:纯 Mockito / 反射,无 Spring / 无 DB / 无外部服务。
+ * 覆盖:① 6 个契约端点 + /deduct-for-wish 的 @PostMapping 路由契约(反射);
+ * ② SC-C-04 WishService 提心愿状态机(正常流 / 积分不足拦截 / 被驳回不可兑 / 越权拒绝);
+ * ③ EnergyService.deductForWish 真实扣减行为(余额充足/不足/非正金额);
+ * ④ EnergyController /deduct-for-wish 参数契约。</p>
+ */
+class ChildWishManageContractTest extends EnergyContractTestBase {
+
+    // ==================== 契约锚点常量(API_REFERENCE §4.4) ====================
+
+    /** WishController 6 个契约端点(任务书设计锚点 create/set-price/reject/exchange/approve-exchange/fulfill) */
+    private static final String[] WISH_CONTRACT_PATHS = {
+            "/create", "/{id}/set-price", "/{id}/reject",
+            "/{id}/exchange", "/{id}/approve-exchange", "/{id}/fulfill"
+    };
+
+    private static final String ENERGY_DEDUCT_FOR_WISH_PATH = "/deduct-for-wish";
+
+    /** 心愿宿主类全名(反射锚定) */
+    private static final String WISH_CONTROLLER_CLASS = "com.etotem.cfc.controller.wish.WishController";
+    private static final String ENERGY_CONTROLLER_CLASS = "com.etotem.cfc.controller.EnergyController";
+
+    // ---- SC-C-04 服务层(WishService)----
+    private WishService wishService;
+    private WishMapper wishMapper;
+    private FamilyMemberMapper familyMemberMapper;
+    private UserMapper userMapper;
+    private CfSpendDispatchService cfSpendDispatchService;
+
+    /** 捕获 DB 中已插入/更新的心愿 */
+    private final List<Wish> wishStore = new ArrayList<>();
+    private long idSeq = 5000L;
+
+    // ---- /deduct-for-wish 服务层(EnergyService)----
+    private EnergyService energyService;
+    private EnergyDimensionMapper dimensionMapper;
+    private EnergyBalanceMapper balanceMapper;
+    private EnergyLogMapper energyLogMapper;
+    private NotificationDispatchService notificationDispatchService;
+
+    @BeforeEach
+    void setUp() throws Exception {
+        // ---------- WishService 装配(仿 WishFlowScP13ContractTest) ----------
+        wishService = new WishService();
+        wishMapper = mock(WishMapper.class);
+        familyMemberMapper = mock(FamilyMemberMapper.class);
+        userMapper = mock(UserMapper.class);
+        cfSpendDispatchService = mock(CfSpendDispatchService.class);
+
+        inject(wishService, "wishMapper", wishMapper);
+        inject(wishService, "familyMemberMapper", familyMemberMapper);
+        inject(wishService, "userMapper", userMapper);
+        inject(wishService, "cfSpendDispatchService", cfSpendDispatchService);
+
+        when(wishMapper.insert(any(Wish.class))).thenAnswer(inv -> {
+            Wish w = inv.getArgument(0);
+            if (w.getId() == null) w.setId(++idSeq);
+            wishStore.add(w);
+            return 1;
+        });
+        when(wishMapper.selectById(any(Long.class))).thenAnswer(inv -> {
+            Long id = inv.getArgument(0);
+            return wishStore.stream().filter(w -> w.getId().equals(id)).findFirst().orElse(null);
+        });
+        when(wishMapper.updateById(any(Wish.class))).thenAnswer(inv -> {
+            Wish w = inv.getArgument(0);
+            for (int i = 0; i < wishStore.size(); i++) {
+                if (wishStore.get(i).getId().equals(w.getId())) wishStore.set(i, w);
+            }
+            return 1;
+        });
+        // 默认:家庭1、成员B 归属,余额 1000,无重复待审批
+        when(userMapper.selectById(1L)).thenReturn(user(1L, 1L));
+        when(familyMemberMapper.selectById(100L)).thenReturn(member(100L, 1L, 1L, "孩子"));
+        when(cfSpendDispatchService.getAvailableForMember(100L)).thenReturn(1000);
+
+        // ---------- EnergyService 装配(仿 EnergyGrantContractTest) ----------
+        energyService = new EnergyService();
+        dimensionMapper = mock(EnergyDimensionMapper.class);
+        balanceMapper = mock(EnergyBalanceMapper.class);
+        energyLogMapper = mock(EnergyLogMapper.class);
+        notificationDispatchService = mock(NotificationDispatchService.class);
+
+        inject(energyService, "energyDimensionMapper", dimensionMapper);
+        inject(energyService, "energyBehaviorConfigMapper",
+                mock(com.etotem.cfc.mapper.EnergyBehaviorConfigMapper.class));
+        inject(energyService, "energyLogMapper", energyLogMapper);
+        inject(energyService, "energyBalanceMapper", balanceMapper);
+        inject(energyService, "familyMemberMapper", familyMemberMapper);
+        inject(energyService, "notificationDispatchService", notificationDispatchService);
+        inject(energyService, "energySourceConfigMapper",
+                mock(com.etotem.cfc.mapper.EnergySourceConfigMapper.class));
+        inject(energyService, "wishMapper", mock(WishMapper.class));
+
+        when(dimensionMapper.selectList(any())).thenReturn(Arrays.asList(fiveDims()));
+        when(familyMemberMapper.selectById(any(Long.class))).thenReturn(member(100L, 1L, 1L, "孩子"));
+
+        initTable(EnergyDimension.class);
+        initTable(EnergyLog.class);
+        initTable(EnergyBalance.class);
+    }
+
+    // =====================================================================
+    // ① 端点契约(反射):6 个 /api/wishes 端点 + /deduct-for-wish
+    // =====================================================================
+
+    @Test
+    @DisplayName("端点契约:WishController /api/wishes 暴露 6 个契约端点")
+    void wishController_exposesSixContractEndpoints() {
+        Class<?> clazz = loadClass(WISH_CONTROLLER_CLASS);
+        RequestMapping classMapping = clazz.getAnnotation(RequestMapping.class);
+        assertNotNull(classMapping, "WishController 应声明类级 @RequestMapping");
+        assertTrue(Arrays.asList(classMapping.value()).contains("/api/wishes"),
+                "WishController 类级路由应为 /api/wishes,实际: " + Arrays.toString(classMapping.value()));
+
+        Set<String> postPaths = postMappingPaths(clazz);
+        for (String path : WISH_CONTRACT_PATHS) {
+            assertTrue(postPaths.contains(path),
+                    "契约端点 POST /api/wishes" + path + " 应已注册(API_REFERENCE §4.4),实际已注册: " + postPaths);
+        }
+    }
+
+    @Test
+    @DisplayName("端点契约:EnergyController 暴露 /deduct-for-wish 积分扣减端点")
+    void energyController_exposesDeductForWishEndpoint() {
+        Class<?> clazz = loadClass(ENERGY_CONTROLLER_CLASS);
+        Set<String> postPaths = postMappingPaths(clazz);
+        assertTrue(postPaths.contains(ENERGY_DEDUCT_FOR_WISH_PATH),
+                "契约端点 POST /api/energy/deduct-for-wish 应已注册,实际已注册: " + postPaths);
+    }
+
+    // =====================================================================
+    // ② SC-C-04 提心愿(WishService 状态机)
+    // =====================================================================
+
+    @Test
+    @DisplayName("SC-C-04 正常流:孩子提心愿 → draft → 家长定价 → active")
+    void c04_正常流_提心愿_定价_active() {
+        Long id = wishService.createByChild(100L, childWish("买乐高"));
+        Wish w = current(id);
+        assertNotNull(w, "提心愿应落库");
+        assertEquals("draft", w.getStatus(), "孩子建心愿初始为 draft(进家长待审批)");
+        assertEquals(100L, w.getMemberId().longValue(), "心愿归属成员应为发起者");
+        assertEquals("child", w.getCreatorType());
+        assertEquals(0, w.getRejectCount().intValue());
+
+        boolean ok = wishService.setPrice(id, 1L, 500);
+        assertTrue(ok, "家长定价应成功");
+        assertEquals("active", current(id).getStatus(), "定价后应 active");
+        assertEquals(500, current(id).getPointsRequired().intValue());
+        assertEquals(1L, current(id).getPointsSetBy().longValue());
+    }
+
+    @Test
+    @DisplayName("SC-C-04 正常流:active 心愿完整兑换-兑现交付闭环")
+    void c04_正常流_兑换审批兑现闭环() {
+        Long id = wishService.createByChild(100L, childWish("玩具"));
+        wishService.setPrice(id, 1L, 300);
+
+        assertTrue(wishService.requestExchange(id, 100L), "申请兑换应成功");
+        assertEquals("pending_exchange", current(id).getStatus());
+
+        assertTrue(wishService.approveExchange(id, 1L, true), "家长批准兑换应成功");
+        assertEquals("approved", current(id).getStatus(), "批准后应 approved");
+        verify(cfSpendDispatchService).spendForMember(100L, 300, "wish_exchange", id, "兑换心愿: 玩具");
+
+        assertTrue(wishService.markFulfilled(id, 1L), "家长标记已购买应成功");
+        assertEquals("exchanging", current(id).getStatus());
+
+        assertTrue(wishService.confirmReceived(id, 100L), "确认收货应成功");
+        assertEquals("fulfilled", current(id).getStatus());
+    }
+
+    @Test
+    @DisplayName("SC-C-04 边界:积分不足申请兑换被拦截(积分超额提示)")
+    void c04_边界_积分不足拦截_状态保持active() {
+        when(cfSpendDispatchService.getAvailableForMember(100L)).thenReturn(10);
+        Long id = wishService.createByChild(100L, childWish("高价心愿"));
+        wishService.setPrice(id, 1L, 300);
+
+        RuntimeException e = assertThrows(RuntimeException.class, () -> wishService.requestExchange(id, 100L));
+        assertTrue(e.getMessage().contains("积分不足"), "实际: " + e.getMessage());
+        assertEquals("active", current(id).getStatus(), "积分不足不应改变状态");
+        verify(cfSpendDispatchService, never()).spendForMember(any(), any(Integer.class), any(), any(), any());
+    }
+
+    @Test
+    @DisplayName("SC-C-04 边界:被驳回不可兑(rejected 后申请兑换被拒)")
+    void c04_边界_被驳回不可兑() {
+        Long id = wishService.createByChild(100L, childWish("被驳回心愿"));
+        assertTrue(wishService.rejectWish(id, 1L, "积分超额"), "家长驳回应成功");
+        assertEquals("rejected", current(id).getStatus());
+        assertEquals(1, current(id).getRejectCount().intValue());
+
+        assertFalse(wishService.requestExchange(id, 100L), "被驳回心愿不可再申请兑换(被驳回不可兑)");
+    }
+
+    @Test
+    @DisplayName("SC-C-04 权限流:非心愿归属成员申请兑换被拒")
+    void c04_权限流_非归属成员申请兑换_拒绝() {
+        Long id = wishService.createByChild(100L, childWish("他人心愿"));
+        wishService.setPrice(id, 1L, 10);
+        assertFalse(wishService.requestExchange(id, 200L), "非归属成员申请兑换应被拒");
+        verify(cfSpendDispatchService, never()).spendForMember(any(), any(Integer.class), any(), any(), any());
+    }
+
+    // =====================================================================
+    // ③ EnergyService.deductForWish 真实扣减行为(/deduct-for-wish 服务契约)
+    // =====================================================================
+
+    @Test
+    @DisplayName("能量扣减正常流:余额充足 → 扣 action 维度并返回 true")
+    void deductForWish_余额充足_扣减成功() {
+        EnergyBalance balance = balance(100L, 3L, 100);
+        when(balanceMapper.selectOne(any())).thenReturn(balance);
+
+        Boolean result = energyService.deductForWish(100L, 5000L, 30L);
+
+        assertTrue(result, "余额充足应扣减成功");
+        assertEquals(70, balance.getBalance().intValue(), "余额应扣减 30");
+        verify(balanceMapper).updateById(any(EnergyBalance.class));
+        verify(energyLogMapper).insert(any(EnergyLog.class));
+    }
+
+    @Test
+    @DisplayName("能量扣减异常流:余额不足 → 返回 false 且不写流水")
+    void deductForWish_余额不足_返回false() {
+        EnergyBalance balance = balance(100L, 3L, 10);
+        when(balanceMapper.selectOne(any())).thenReturn(balance);
+
+        Boolean result = energyService.deductForWish(100L, 5001L, 30L);
+
+        assertFalse(result, "余额不足应返回 false");
+        assertEquals(10, balance.getBalance().intValue(), "余额不应变化");
+        verify(balanceMapper, never()).updateById(any(EnergyBalance.class));
+        verify(energyLogMapper, never()).insert(any(EnergyLog.class));
+    }
+
+    @Test
+    @DisplayName("能量扣减异常流:金额非正抛非法参数")
+    void deductForWish_金额非正_抛非法参数() {
+        when(balanceMapper.selectOne(any())).thenReturn(balance(100L, 3L, 100));
+        IllegalArgumentException e = assertThrows(IllegalArgumentException.class,
+                () -> energyService.deductForWish(100L, 5002L, 0L));
+        assertTrue(e.getMessage().contains("扣除数量必须为正数"), "实际: " + e.getMessage());
+    }
+
+    // =====================================================================
+    // ④ EnergyController /deduct-for-wish 参数契约
+    // =====================================================================
+
+    @Test
+    @DisplayName("deduct-for-wish 参数契约:必填缺失返回业务错误")
+    void deductForWish_api_必填缺失_返回错误() throws Exception {
+        EnergyController controller = new EnergyController();
+        inject(controller, "energyService", energyService);
+        inject(controller, "userMapper", mock(UserMapper.class));
+        inject(controller, "unlockGateService", mock(com.etotem.cfc.service.UnlockGateService.class));
+        inject(controller, "fiveDimensionScoreService",
+                mock(com.etotem.cfc.service.FiveDimensionScoreService.class));
+
+        Result<Boolean> result = controller.deductForWish(new HashMap<>(), 1L, null);
+
+        assertEquals(500, result.getCode().intValue());
+        assertTrue(result.getMessage().contains("不能为空"), "实际: " + result.getMessage());
+    }
+
+    @Test
+    @DisplayName("deduct-for-wish 参数契约:无家庭返回 5001")
+    void deductForWish_api_无家庭_返回5001() throws Exception {
+        EnergyController controller = new EnergyController();
+        inject(controller, "energyService", energyService);
+        inject(controller, "userMapper", mock(UserMapper.class));
+        inject(controller, "unlockGateService", mock(com.etotem.cfc.service.UnlockGateService.class));
+        inject(controller, "fiveDimensionScoreService",
+                mock(com.etotem.cfc.service.FiveDimensionScoreService.class));
+
+        Map<String, Object> params = new HashMap<>();
+        params.put("memberId", 100L);
+        params.put("wishId", 5000L);
+        params.put("amount", 30L);
+
+        Result<Boolean> result = controller.deductForWish(params, null, null);
+
+        assertEquals(5001, result.getCode().intValue(), "无家庭应返回 5001 noFamily");
+        assertTrue(result.getMessage().contains("家庭"), "实际: " + result.getMessage());
+    }
+
+    // =====================================================================
+    // 辅助构造
+    // =====================================================================
+
+    private static Class<?> loadClass(String name) {
+        try {
+            return Class.forName(name);
+        } catch (ClassNotFoundException e) {
+            fail("宿主类 " + name + " 不存在: " + e.getMessage());
+            throw new IllegalStateException("unreachable");
+        }
+    }
+
+    /** 收集类上所有 @PostMapping 的 value 路径 */
+    private static Set<String> postMappingPaths(Class<?> clazz) {
+        Set<String> paths = new HashSet<>();
+        for (Method m : clazz.getDeclaredMethods()) {
+            PostMapping pm = m.getAnnotation(PostMapping.class);
+            if (pm != null) {
+                paths.addAll(Arrays.asList(pm.value()));
+            }
+        }
+        return paths;
+    }
+
+    private static User user(Long id, Long familyId) {
+        User u = new User();
+        u.setId(id);
+        u.setFamilyId(familyId);
+        return u;
+    }
+
+    private static FamilyMember member(Long id, Long familyId, Long userId, String nickname) {
+        FamilyMember m = new FamilyMember();
+        m.setId(id);
+        m.setFamilyId(familyId);
+        m.setUserId(userId);
+        m.setNickname(nickname);
+        return m;
+    }
+
+    private static CreateWishDTO childWish(String title) {
+        CreateWishDTO dto = new CreateWishDTO();
+        dto.setTitle(title);
+        return dto;
+    }
+
+    /** 能量余额构造(totalEarned/totalSpent 必须非空,deductEnergy 会做 totalSpent + amount 自增) */
+    private static EnergyBalance balance(Long memberId, Long dimId, int balance) {
+        EnergyBalance b = new EnergyBalance();
+        b.setMemberId(memberId);
+        b.setDimensionId(dimId);
+        b.setBalance(balance);
+        b.setTotalEarned(0);
+        b.setTotalSpent(0);
+        return b;
+    }
+
+    /** 在 store 中检索指定 id 的当前心愿对象 */
+    private Wish current(Long id) {
+        return wishStore.stream().filter(w -> w.getId().equals(id)).findFirst().orElse(null);
+    }
+}