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