Browse Source

merge(task/S-cfc-212): child.task.checkin TDD-RED 契约测试(5红/4绿) 合入 cfclub 主线

Sisyphus 2 hours ago
parent
commit
50618fafaf

+ 326 - 0
cfc-backend/src/test/java/com/etotem/cfc/unit/TaskCheckinChildContractTest.java

@@ -0,0 +1,326 @@
+package com.etotem.cfc.unit;
+
+import com.baomidou.mybatisplus.core.conditions.Wrapper;
+import com.etotem.cfc.entity.FamilyMember;
+import com.etotem.cfc.entity.MiniGame;
+import com.etotem.cfc.entity.PointsLog;
+import com.etotem.cfc.entity.Task;
+import com.etotem.cfc.mapper.FamilyMemberMapper;
+import com.etotem.cfc.mapper.MiniGameMapper;
+import com.etotem.cfc.mapper.PointsLogMapper;
+import com.etotem.cfc.mapper.TaskMapper;
+import com.etotem.cfc.service.GameRecordService;
+import com.etotem.cfc.service.MiniGameService;
+import com.etotem.cfc.service.TaskService;
+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 java.lang.annotation.Annotation;
+import java.lang.reflect.Field;
+import java.lang.reflect.Method;
+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.assertNull;
+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.verify;
+import static org.mockito.Mockito.when;
+
+/**
+ * 契约级单测:孩子端任务打卡(能力键 {@code child.task.checkin},TDD-RED 阶段)。
+ *
+ * <p><b>契约溯源</b></p>
+ * <ul>
+ *   <li>需求:{@code tests/user-scenarios/应用场景总览.md} § 四——SC-C-02(执行任务/小游戏得积分)、
+ *       SC-C-05(任务打卡⭐)、SC-C-08(方案打卡进度)、SC-C-14(任务驳回重做);</li>
+ *   <li>设计:{@code docs/superpowers/api/API_REFERENCE.md} §4.6(成长档案 {@code /api/growth/*});
+ *       {@code TaskController}(完成/审核/驳回)+ {@code GrowthTaskController}(打卡提交/进度查询/驳回重做)
+ *       + {@code GrowthPlanController} {@code /api/growth/plan/child/{id}/active};</li>
+ *   <li>契约核对:{@code docs/superpowers/specs/2026-11-07-child-zone-contract.md}
+ *       §2(SC-C-05/C-08/C-14 端点核验)+ §5 G-09(小游戏无幂等/无分数上限);</li>
+ *   <li>风格:与已合入主链的同批 RED 契约测试({@code TeamProfitHookContractTest} /
+ *       {@code FamilyJoinValidationRulesContractTest})一致——纯 Mockito + 反射,无 Spring / 无 DB / 无外部服务。</li>
+ * </ul>
+ *
+ * <p><b>实现现状(本分支 src/main 实测)</b>:</p>
+ * <ul>
+ *   <li><b>打卡提交(SC-C-05)</b>:{@code TaskController} {@code POST /api/tasks/{id}/complete} 已存在(含 T24-A 幂等);
+ *       但设计锚点 {@code GrowthTaskController} 无任何 {@code checkin} 提交端点(仅 {@code /list /claim /accept-dynamic})——<b>缺口</b>;</li>
+ *   <li><b>方案打卡进度(SC-C-08)</b>:{@code GrowthPlanController} 无 {@code progress} 查询端点(仅 create/child/active/detail/update/review/list)——<b>缺口</b>;</li>
+ *   <li><b>驳回重做(SC-C-14)</b>:{@code TaskService.reviewTask(approved=false)} 已把 pending_review 回 pending、清 completedAt,可重做(已落地);</li>
+ *   <li><b>小游戏分数上限(SC-C-02,G-09)</b>:{@code MiniGameService.calculatePoints} 对 {@code score>100} 无上限,
+ *       {@code score=999999} 可换巨额积分——<b>缺口</b>。</li>
+ * </ul>
+ *
+ * <p><b>RED 预期</b>:缺口项(打卡提交端点 / 方案进度端点 / 进度契约 VO / 小游戏分数上限)在实现补齐前应失败(红);
+ * 已落地项(重复打卡幂等 / 驳回重做 / 活跃方案锚点)实测为绿,作「已落地」证据上报,不私改实现。</p>
+ *
+ * <p><b>GREEN 升级指引</b>:实现者补齐 ①{@code GrowthTaskController} 增加 {@code @PostMapping("/checkin")} 提交端点;
+ * ②{@code GrowthPlanController} 增加 {@code @PostMapping("/child/{memberId}/progress")} 进度端点(返回含
+ * {@code completedTasks/totalTasks/progressPercent} 的 VO);③小游戏 {@code calculatePoints} 对 {@code score}
+ * 限 {@code 0~100}(或按游戏配置),且 {@code completeGame} 增加 {@code (memberId,gameCode,date)} 判重幂等;
+ * 本契约测试自动转绿。</p>
+ *
+ * <p><b>权限流说明</b>:打卡归属以「成员匹配」维度在 {@code completeTask} 内体现({@code familyMemberId == memberId});
+ * 跨家庭归属校验(契约核对 G-10/G-11)为已知跨切面缺口,由编排侧登记,不在本测试内新造断言。</p>
+ */
+class TaskCheckinChildContractTest {
+
+    /** 设计锚点:打卡提交/进度查询承载类 */
+    private static final String GROWTH_TASK_CONTROLLER = "com.etotem.cfc.controller.GrowthTaskController";
+    private static final String GROWTH_PLAN_CONTROLLER = "com.etotem.cfc.controller.growth.GrowthPlanController";
+
+    /** 契约要求的打卡提交 DTO(SC-C-05 打卡提交入参) */
+    private static final String TASK_CHECKIN_DTO = "com.etotem.cfc.dto.TaskCheckinDTO";
+
+    /** 契约要求的方案进度 VO(SC-C-08 进度查询出参) */
+    private static final String PLAN_PROGRESS_VO = "com.etotem.cfc.dto.GrowthPlanProgressVO";
+
+    // ==================== 正常流 · 打卡提交(SC-C-05) ====================
+
+    @Test
+    @DisplayName("SC-C-05 正常流-1:GrowthTaskController 应暴露「打卡提交」端点")
+    void c05_打卡提交端点应存在() {
+        Class<?> controller = loadClass(GROWTH_TASK_CONTROLLER);
+        assertNotNull(controller,
+                "[child.task.checkin] GrowthTaskController 类不存在,无法承载方案任务打卡提交(API_REFERENCE §4.6)。");
+        boolean hasCheckinEndpoint = false;
+        for (Method m : controller.getDeclaredMethods()) {
+            if (postMappingPathContains(m, "checkin")) {
+                hasCheckinEndpoint = true;
+                break;
+            }
+        }
+        assertTrue(hasCheckinEndpoint,
+                "[child.task.checkin] GrowthTaskController 应暴露 @PostMapping 打卡提交端点(path 含 \"checkin\")。"
+                        + " 当前仅 /list /claim /accept-dynamic,无打卡提交入口(SC-C-05 任务打卡⭐,设计锚点未落地)。");
+    }
+
+    @Test
+    @DisplayName("SC-C-05 正常流-2:应存在打卡提交契约 DTO(memberId/taskId/photoUrl/content)")
+    void c05_打卡提交契约DTO应存在() {
+        Class<?> dto = loadClass(TASK_CHECKIN_DTO);
+        if (dto == null) {
+            fail("[child.task.checkin] 契约 DTO " + TASK_CHECKIN_DTO + " 不存在:"
+                    + "打卡提交入参未契约化(SC-C-05)。");
+        }
+        assertTrue(hasField(dto, "memberId"),
+                "[child.task.checkin] TaskCheckinDTO 应含 memberId(打卡归属成员,SC-C-05)。");
+        assertTrue(hasField(dto, "taskId"),
+                "[child.task.checkin] TaskCheckinDTO 应含 taskId(被打卡任务,SC-C-05)。");
+        assertTrue(hasField(dto, "photoUrl") || hasField(dto, "content"),
+                "[child.task.checkin] TaskCheckinDTO 应含提交凭证 photoUrl/content(SC-C-05 打卡提交证据)。");
+    }
+
+    // ==================== 正常流 · 方案打卡进度(SC-C-08) ====================
+
+    @Test
+    @DisplayName("SC-C-08 正常流-3:GrowthPlanController 应暴露「方案进度」查询端点")
+    void c08_方案进度端点应存在() {
+        Class<?> controller = loadClass(GROWTH_PLAN_CONTROLLER);
+        assertNotNull(controller,
+                "[child.task.checkin] GrowthPlanController 类不存在,无法承载方案打卡进度(SC-C-08)。");
+        boolean hasProgressEndpoint = false;
+        for (Method m : controller.getDeclaredMethods()) {
+            if (postMappingPathContains(m, "progress")) {
+                hasProgressEndpoint = true;
+                break;
+            }
+        }
+        assertTrue(hasProgressEndpoint,
+                "[child.task.checkin] GrowthPlanController 应暴露 @PostMapping 方案进度端点(path 含 \"progress\")。"
+                        + " 当前无进度查询入口(SC-C-08 方案打卡进度,进度计算未落地)。");
+    }
+
+    @Test
+    @DisplayName("SC-C-08 正常流-4:应存在方案进度契约 VO(completedTasks/totalTasks/progressPercent)")
+    void c08_方案进度契约VO应含完成率() {
+        Class<?> vo = loadClass(PLAN_PROGRESS_VO);
+        if (vo == null) {
+            fail("[child.task.checkin] 契约 VO " + PLAN_PROGRESS_VO + " 不存在:"
+                    + "方案打卡进度出参未契约化(SC-C-08)。");
+        }
+        assertTrue(hasField(vo, "completedTasks"),
+                "[child.task.checkin] GrowthPlanProgressVO 应含 completedTasks(已完成任务数,SC-C-08)。");
+        assertTrue(hasField(vo, "totalTasks"),
+                "[child.task.checkin] GrowthPlanProgressVO 应含 totalTasks(方案任务总数,SC-C-08)。");
+        assertTrue(hasField(vo, "progressPercent"),
+                "[child.task.checkin] GrowthPlanProgressVO 应含 progressPercent(完成率 0-100,SC-C-08 进度计算正确)。");
+    }
+
+    // ==================== 正常流 · 活跃方案锚点(harness 自检,已落地) ====================
+
+    @Test
+    @DisplayName("SC-C-05 锚点:活跃方案入口 /api/growth/plan/child/{id}/active 应存在(已落地)")
+    void c05_活跃方案入口锚点() {
+        Class<?> controller = loadClass(GROWTH_PLAN_CONTROLLER);
+        assertNotNull(controller);
+        boolean hasActiveEndpoint = false;
+        for (Method m : controller.getDeclaredMethods()) {
+            if (postMappingPathContains(m, "active")) {
+                hasActiveEndpoint = true;
+                break;
+            }
+        }
+        assertTrue(hasActiveEndpoint,
+                "[child.task.checkin] 活跃方案锚点 /api/growth/plan/child/{id}/active 应存在(设计锚点,已落地)。");
+    }
+
+    // ==================== 边界流 · 重复打卡幂等(SC-C-02/C-05) ====================
+
+    @Test
+    @DisplayName("SC-C-05 边界流-1:重复打卡(任务已 completed)应幂等、不重复发奖")
+    void c05_重复打卡_幂等不重复发奖() {
+        Task t = new Task();
+        t.setId(10L);
+        t.setStatus("completed");
+        when(taskMapper.selectById(10L)).thenReturn(t);
+        when(familyMemberMapper.selectById(100L)).thenReturn(member(100L, 88));
+
+        Map<String, Object> result = taskService.completeTask(10L, 100L, null, "已完成", "text");
+
+        assertNotNull(result, "重复打卡不应返回 null(T24-A 幂等)。");
+        assertEquals(0, ((Number) result.get("pointsEarned")).intValue(), "重复打卡不重复发奖。");
+        assertEquals(Boolean.TRUE, result.get("alreadyCompleted"), "应返回已完成的幂等信号。");
+    }
+
+    // ==================== 边界流 · 驳回重做(SC-C-14) ====================
+
+    @Test
+    @DisplayName("SC-C-14 边界流-2:驳回 pending_review 后状态回 pending、清 completedAt,可重做")
+    void c14_驳回后_状态回pending_可重做() {
+        Task t = new Task();
+        t.setId(50L);
+        t.setFamilyMemberId(100L);
+        t.setStatus("pending_review");
+        t.setCompletedAt(new java.util.Date());
+        when(taskMapper.selectById(50L)).thenReturn(t);
+
+        boolean ok = taskService.reviewTask(50L, reviewDto(false));
+        assertTrue(ok, "驳回应成功");
+        assertEquals("pending", t.getStatus(), "驳回后应回 pending(可重做再提交)。");
+        assertNull(t.getCompletedAt(), "驳回后应清空 completedAt。");
+        verify(taskMapper).updateById(any(Task.class));
+    }
+
+    // ==================== 边界流 · 小游戏分数上限(SC-C-02 / G-09) ====================
+
+    @Test
+    @DisplayName("SC-C-02 边界流-3:小游戏越界分数(score>100)应被拦截,不得换取超额积分")
+    void c02_小游戏_越界分数应拦截() {
+        MiniGame game = new MiniGame();
+        game.setGameCode("1a2b");
+        game.setGameName("猜数字");
+        game.setDefaultPoints(5);
+        game.setStatus(1);
+        when(miniGameMapper.selectOne(any(Wrapper.class))).thenReturn(game);
+        when(familyMemberMapper.selectById(100L)).thenReturn(member(100L, 50));
+        when(pointsLogMapper.insert(any(PointsLog.class))).thenReturn(1);
+
+        // 越界分数:score=999999(契约:score 应限 0~100 或按游戏配置,G-09)
+        Map<String, Object> result = miniGameService.completeGame(100L, "1a2b", 10, 999999);
+
+        int basePoints = game.getDefaultPoints();
+        int pointsEarned = ((Number) result.get("pointsEarned")).intValue();
+        // 契约边界:60-100 分档最多 2×基础分(+ 游戏特定加成);越界分数不得突破合理上限
+        int contractCap = basePoints * 2 + 3; // 2×base + 1a2b 用时加成上限
+        assertTrue(pointsEarned <= contractCap,
+                "[child.task.checkin] 小游戏越界分数应拦截:score=999999 实得 " + pointsEarned
+                        + " 分,超出契约上限 " + contractCap + "(G-09:score 应限 0~100,无上限为缺口)。");
+    }
+
+    // ==================== 权限流(说明性) ====================
+
+    @Test
+    @DisplayName("权限流:打卡归属以成员匹配维度体现;跨家庭归属校验为已知跨切面缺口(G-10/G-11)")
+    void permissionFlow_memberMatch() {
+        // 说明性用例:打卡归属由 completeTask 内 familyMemberId==memberId 匹配体现;
+        // 跨家庭归属校验(G-10 能量/G-11 成长档案)为契约核对已登记的跨切面缺口,由编排侧转实现,不在此新造断言。
+        assertTrue(true, "打卡归属=成员匹配(completeTask);跨家庭归属校验为 G-10/G-11 已知缺口。");
+    }
+
+    // ==================== 依赖注入与辅助 ====================
+
+    private TaskService taskService;
+    private TaskMapper taskMapper;
+    private FamilyMemberMapper familyMemberMapper;
+    private MiniGameService miniGameService;
+    private MiniGameMapper miniGameMapper;
+    private PointsLogMapper pointsLogMapper;
+    private GameRecordService gameRecordService;
+
+    @BeforeEach
+    void setUp() throws Exception {
+        taskService = new TaskService();
+        taskMapper = mock(TaskMapper.class);
+        familyMemberMapper = mock(FamilyMemberMapper.class);
+        inject(taskService, "taskMapper", taskMapper);
+        inject(taskService, "familyMemberMapper", familyMemberMapper);
+
+        miniGameService = new MiniGameService();
+        miniGameMapper = mock(MiniGameMapper.class);
+        pointsLogMapper = mock(PointsLogMapper.class);
+        gameRecordService = mock(GameRecordService.class);
+        inject(miniGameService, "miniGameMapper", miniGameMapper);
+        inject(miniGameService, "familyMemberMapper", familyMemberMapper);
+        inject(miniGameService, "pointsLogMapper", pointsLogMapper);
+        inject(miniGameService, "gameRecordService", gameRecordService);
+    }
+
+    private static void inject(Object target, String field, Object value) throws Exception {
+        Field f = target.getClass().getDeclaredField(field);
+        f.setAccessible(true);
+        f.set(target, value);
+    }
+
+    private static FamilyMember member(Long id, int totalPoints) {
+        FamilyMember m = new FamilyMember();
+        m.setId(id);
+        m.setTotalPoints(totalPoints);
+        return m;
+    }
+
+    private static com.etotem.cfc.dto.TaskReviewDTO reviewDto(boolean approved) {
+        com.etotem.cfc.dto.TaskReviewDTO dto = new com.etotem.cfc.dto.TaskReviewDTO();
+        dto.setApproved(approved);
+        return dto;
+    }
+
+    private Class<?> loadClass(String name) {
+        try {
+            return Class.forName(name, false, Thread.currentThread().getContextClassLoader());
+        } catch (ClassNotFoundException e) {
+            return null;
+        }
+    }
+
+    private boolean hasField(Class<?> clazz, String name) {
+        for (Field f : clazz.getDeclaredFields()) {
+            if (f.getName().equals(name)) {
+                return true;
+            }
+        }
+        return false;
+    }
+
+    /** 反射提取方法上 @PostMapping 的 path,判断是否含指定子串 */
+    private boolean postMappingPathContains(Method m, String fragment) {
+        for (Annotation a : m.getDeclaredAnnotations()) {
+            if (a.annotationType().equals(PostMapping.class)) {
+                PostMapping pm = (PostMapping) a;
+                for (String path : pm.value()) {
+                    if (path != null && path.contains(fragment)) {
+                        return true;
+                    }
+                }
+            }
+        }
+        return false;
+    }
+}