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

test(S-cfc-150): task.task.complete 契约级单测 RED — 正常/幂等/权限/控制器契约11例,超期提交被拒锚点1红

Sisyphus 1 день назад
Родитель
Сommit
3d0c2701f8

+ 362 - 0
cfc-backend/src/test/java/com/etotem/cfc/unit/TaskCompleteScC02ContractTest.java

@@ -0,0 +1,362 @@
+package com.etotem.cfc.unit;
+
+import com.etotem.cfc.common.Result;
+import com.etotem.cfc.controller.task.TaskController;
+import com.etotem.cfc.dto.CompleteTaskDTO;
+import com.etotem.cfc.entity.FamilyMember;
+import com.etotem.cfc.entity.PointsLog;
+import com.etotem.cfc.entity.Task;
+import com.etotem.cfc.entity.TaskExecution;
+import com.etotem.cfc.mapper.FamilyMemberMapper;
+import com.etotem.cfc.mapper.PointsLogMapper;
+import com.etotem.cfc.mapper.TaskExecutionMapper;
+import com.etotem.cfc.mapper.TaskMapper;
+import com.etotem.cfc.service.CfPathwayEarnService;
+import com.etotem.cfc.service.EnergyService;
+import com.etotem.cfc.service.MembershipService;
+import com.etotem.cfc.service.NotificationDispatchService;
+import com.etotem.cfc.service.ProblemCompletionService;
+import com.etotem.cfc.service.ProfileComputeService;
+import com.etotem.cfc.service.StreakService;
+import com.etotem.cfc.service.TaskReminderConfigService;
+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.mockito.ArgumentCaptor;
+
+import java.lang.reflect.Field;
+import java.util.Date;
+import java.util.HashMap;
+import java.util.Map;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertNull;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.ArgumentMatchers.anyInt;
+import static org.mockito.ArgumentMatchers.anyLong;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.never;
+import static org.mockito.Mockito.times;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+
+/**
+ * 任务完成打卡(能力键 {@code task.task.complete})契约级单测(SC-C-02 / TC-C-02 / TC-C-02-E / TC-C-02-P)。
+ *
+ * <p><b>契约锚点</b></p>
+ * <ul>
+ *   <li>需求:docs/tdd/SRS.md «三、未覆盖控制器» TaskController(18 方法,0 覆盖)</li>
+ *   <li>设计:TaskController.completeTask {@code @PostMapping("/{id}/complete")} + TaskService.completeTask</li>
+ *   <li>契约:docs/superpowers/api/API_REFERENCE.md §4.3 / §4.61.2 — {@code POST /api/tasks/{id}/complete},
+ *       请求 {@code CompleteTaskDTO{memberId?, photoUrl, content, contentType}},响应 {@code Result<Map>}(奖励/状态),打卡提交</li>
+ *   <li>AC 条款:tests/test-cases/child.md TC-C-02(正常流:提交完成→待审核/写记录/重复提交不重复计分)、
+ *       TC-C-02-E(异常流:重复/超期/驳回重做)、TC-C-02-P(权限流:非本人不越权)</li>
+ * </ul>
+ *
+ * <p><b>测试范围</b>:纯 Mockito + 反射(无 Spring 上下文 / 无 DB / 无外部服务),
+ * 直接驱动 {@link TaskService#completeTask} 与 {@link TaskController#completeTask},
+ * 风格对齐同批任务测试(FamilyJoinValidationRulesContractTest / TaskReviewScT06ContractTest)。</p>
+ *
+ * <p><b>RED 预期(TDD-RED)</b>:本键为契约新增能力,src/main 现状核实:
+ * 端点/正常流/幂等/越权不发奖等大部分契约行为已落地(对应用例为绿);
+ * 但异常流 AC 条款「超期任务仍提交 → 被拒」(TC-C-02-E)在 {@code completeTask} 中
+ * 无 deadline 守卫:任务截止时间已过仍走「迟到扣分」完成链路并落库发奖。
+ * 因此「超期提交必须被拒(不完成、不发奖)」用例断言失败(红),
+ * GREEN 阶段需在 {@code completeTask} 补服务端超期守卫后转绿。</p>
+ *
+ * <p>「并发双点幂等仅计 1 次」以服务层顺序重复提交等价验证(同一任务两次调用仅 1 条积分流水)、
+ * 与 T24-A 幂等分支(已完成/待审核返回 alreadyCompleted 不再发奖)实现一致。</p>
+ */
+class TaskCompleteScC02ContractTest {
+
+    private TaskService service;
+    private TaskController controller;
+    private TaskService taskServiceMock; // controller 层转发的 mock(与 service 层真实实例隔离)
+
+    private TaskMapper taskMapper;
+    private FamilyMemberMapper familyMemberMapper;
+    private PointsLogMapper pointsLogMapper;
+    private TaskExecutionMapper taskExecutionMapper;
+    private StreakService streakService;
+    private MembershipService membershipService;
+    private ProblemCompletionService problemCompletionService;
+    private ProfileComputeService profileComputeService;
+
+    @BeforeEach
+    void setUp() throws Exception {
+        // ============ service 层:真实 TaskService + mock 依赖 ============
+        service = new TaskService();
+        taskMapper = mock(TaskMapper.class);
+        familyMemberMapper = mock(FamilyMemberMapper.class);
+        pointsLogMapper = mock(PointsLogMapper.class);
+        taskExecutionMapper = mock(TaskExecutionMapper.class);
+        streakService = mock(StreakService.class);
+        EnergyService energyService = mock(EnergyService.class);
+        CfPathwayEarnService cfPathwayEarnService = mock(CfPathwayEarnService.class);
+        TaskReminderConfigService taskReminderConfigService = mock(TaskReminderConfigService.class);
+        NotificationDispatchService notificationDispatchService = mock(NotificationDispatchService.class);
+
+        inject(service, "taskMapper", taskMapper);
+        inject(service, "familyMemberMapper", familyMemberMapper);
+        inject(service, "pointsLogMapper", pointsLogMapper);
+        inject(service, "taskExecutionMapper", taskExecutionMapper);
+        inject(service, "streakService", streakService);
+        inject(service, "energyService", energyService);
+        inject(service, "cfPathwayEarnService", cfPathwayEarnService);
+        inject(service, "taskReminderConfigService", taskReminderConfigService);
+        inject(service, "notificationDispatchService", notificationDispatchService);
+
+        when(taskReminderConfigService.loadAllConfigs()).thenReturn(new HashMap<>());
+        when(streakService.checkAndAwardMilestone(anyLong(), anyInt())).thenReturn(0);
+
+        // ============ controller 层:真实 TaskController + mock 依赖 ============
+        controller = new TaskController();
+        taskServiceMock = mock(TaskService.class);
+        membershipService = mock(MembershipService.class);
+        problemCompletionService = mock(ProblemCompletionService.class);
+        profileComputeService = mock(ProfileComputeService.class);
+        inject(controller, "taskService", taskServiceMock);
+        inject(controller, "taskMapper", taskMapper);
+        inject(controller, "membershipService", membershipService);
+        inject(controller, "problemCompletionService", problemCompletionService);
+        inject(controller, "profileComputeService", profileComputeService);
+    }
+
+    // ==================== 正常流(TC-C-02) ====================
+
+    @Test
+    @DisplayName("正常流-1:需审核任务提交打卡 → 状态待家长审核、写入执行记录、不发积分")
+    void c02_正常流_需审核任务_提交进入待审核() {
+        Task t = task(11L, 100L, "pending", 1, new Date(System.currentTimeMillis() + 3_600_000L));
+        when(taskMapper.selectById(11L)).thenReturn(t);
+        when(familyMemberMapper.selectById(100L)).thenReturn(member(100L, 50, 0));
+
+        Map<String, Object> result = service.completeTask(11L, 100L, null, "完成睡前阅读", "text");
+
+        assertEquals(Boolean.TRUE, result.get("needReview"), "需审核任务应返回 needReview=true(待家长审核)");
+        assertEquals(0, result.get("pointsEarned"), "审核未通过前不应发积分");
+        assertEquals("pending_review", t.getStatus(), "状态应流转到 pending_review");
+        assertTrue(t.getCompletedAt() != null, "应写入完成时间(供家长审核)");
+        verify(pointsLogMapper, never()).insert(any(PointsLog.class));
+        verify(taskExecutionMapper).insert(any(TaskExecution.class)); // 完成动作写入记录
+    }
+
+    @Test
+    @DisplayName("正常流-2:免审核任务按时完成 → 发积分(提前完成+1)、积分流水 1 条")
+    void c02_正常流_免审核任务_完成发积分() {
+        Task t = task(12L, 100L, "pending", 0, new Date(System.currentTimeMillis() + 3_600_000L));
+        when(taskMapper.selectById(12L)).thenReturn(t);
+        FamilyMember child = member(100L, 20, 0);
+        when(familyMemberMapper.selectById(100L)).thenReturn(child);
+
+        Map<String, Object> result = service.completeTask(12L, 100L, null, "读书打卡", "text");
+
+        // 提前完成:10 分 + 提前奖励 1 分;newBalance = 20 + 11
+        assertEquals(11, result.get("pointsEarned"), "提前完成应得 points+EARLY_BONUS(1)");
+        assertEquals(31, result.get("newBalance"), "完成后余额应更新");
+        assertEquals(Boolean.FALSE, result.get("needReview"));
+        assertEquals("completed", t.getStatus(), "免审核任务应直接完成");
+
+        ArgumentCaptor<PointsLog> cap = ArgumentCaptor.forClass(PointsLog.class);
+        verify(pointsLogMapper, times(1)).insert(cap.capture());
+        assertEquals(11, cap.getValue().getAmount(), "积分流水金额应与发放一致");
+        assertEquals(100L, cap.getValue().getMemberId());
+    }
+
+    @Test
+    @DisplayName("正常流-3:重复提交(已完成任务再提交)→ 幂等成功、不重复计分不发奖")
+    void c02_正常流_重复提交_幂等不重复计分() {
+        Task t = task(13L, 100L, "completed", 0, new Date(System.currentTimeMillis() + 3_600_000L));
+        when(taskMapper.selectById(13L)).thenReturn(t);
+        when(familyMemberMapper.selectById(100L)).thenReturn(member(100L, 30, 0));
+
+        Map<String, Object> result = service.completeTask(13L, 100L, null, null, null);
+
+        assertEquals(Boolean.TRUE, result.get("alreadyCompleted"), "重复提交应幂等返回已完成标识");
+        assertEquals(0, result.get("pointsEarned"), "重复提交不应再计分");
+        verify(taskMapper, never()).updateById(any(Task.class));
+        verify(pointsLogMapper, never()).insert(any(PointsLog.class));
+    }
+
+    @Test
+    @DisplayName("异常流-0:同一任务连续提交两次「完成」→ 仅 1 条积分流水(TC-C-02-E 重复/并发双点等价验证)")
+    void c02_异常流_同任务连点两次_仅1条积分流水() {
+        Task t = task(14L, 100L, "pending", 0, new Date(System.currentTimeMillis() + 3_600_000L));
+        when(taskMapper.selectById(14L)).thenReturn(t);
+        when(familyMemberMapper.selectById(100L)).thenReturn(member(100L, 20, 0));
+
+        service.completeTask(14L, 100L, null, "打卡", "text");
+        service.completeTask(14L, 100L, null, "打卡", "text");
+
+        verify(pointsLogMapper, times(1)).insert(any(PointsLog.class));
+        assertEquals("completed", t.getStatus(), "最终状态应为 completed(不重复跃迁)");
+    }
+
+    // ==================== 异常流(TC-C-02-E) ====================
+
+    @Test
+    @DisplayName("异常流-1:超期任务仍提交应被拒(RED:deadline 守卫缺失)")
+    void c02e_异常流_超期任务提交_应被拒() {
+        // 截止时间已过 1 小时,任务仍为 pending
+        Task t = task(99L, 100L, "pending", 0, new Date(System.currentTimeMillis() - 3_600_000L));
+        when(taskMapper.selectById(99L)).thenReturn(t);
+        when(familyMemberMapper.selectById(100L)).thenReturn(member(100L, 20, 0));
+
+        // 契约(TC-C-02-E):超期任务仍提交 → 被拒(不允许完成、不得发奖)。
+        // 现状:无 deadline 守卫,走"迟到扣分"完成链路 → 断言失败(红)。
+        assertThrows(IllegalArgumentException.class,
+                () -> service.completeTask(99L, 100L, null, "逾期补交", "text"),
+                "[task.task.complete] 超期任务提交必须被拒(TC-C-02-E):当前实现无超期守卫,"
+                        + "仍在完成并发奖。GREEN 阶段应在 completeTask 补 deadline 拦截。");
+        verify(taskMapper, never()).updateById(any(Task.class));
+        verify(pointsLogMapper, never()).insert(any(PointsLog.class));
+    }
+
+    @Test
+    @DisplayName("异常流-2:任务不存在 → 返回 null(上游转错误提示,杜绝静默成功)")
+    void c02_异常流_任务不存在_返回null() {
+        when(taskMapper.selectById(999L)).thenReturn(null);
+        assertNull(service.completeTask(999L, 100L, null, null, null));
+    }
+
+    // ==================== 权限流(TC-C-02-P) ====================
+
+    @Test
+    @DisplayName("权限流-1:非本人成员提交 → 不发奖、不修改任务状态")
+    void c02_权限流_非本人成员提交_不发奖不改状态() {
+        Task t = task(15L, 100L, "pending", 0, new Date(System.currentTimeMillis() + 3_600_000L)); // 归属成员 100
+        when(taskMapper.selectById(15L)).thenReturn(t);
+        when(familyMemberMapper.selectById(999L)).thenReturn(member(999L, 5, 0)); // 越权成员 999
+
+        Map<String, Object> result = service.completeTask(15L, 999L, null, "替别人打卡", "text");
+
+        assertEquals(Boolean.TRUE, result.get("alreadyCompleted"), "越权提交应幂等空结果");
+        assertEquals(0, result.get("pointsEarned"), "越权提交不得发奖");
+        assertEquals("pending", t.getStatus(), "任务状态不得被非本人提交修改");
+        verify(taskMapper, never()).updateById(any(Task.class));
+        verify(pointsLogMapper, never()).insert(any(PointsLog.class));
+    }
+
+    @Test
+    @DisplayName("权限流-2:会员任务非会员提交 → 拒绝(提示开通会员),不触达 Service")
+    void c02_权限流_会员任务非会员_拒绝() {
+        Task t = new Task();
+        t.setId(55L);
+        t.setFamilyId(7L);
+        t.setMemberOnly(1);
+        when(taskMapper.selectById(55L)).thenReturn(t);
+        when(membershipService.getCurrentLevel(7L)).thenReturn(null); // 非会员
+
+        CompleteTaskDTO dto = new CompleteTaskDTO();
+        dto.setMemberId(100L);
+        Result<Map<String, Object>> result = controller.completeTask(55L, dto, 200L);
+
+        assertEquals(Integer.valueOf(500), result.getCode());
+        assertTrue(result.getMessage().contains("会员"), "应提示开通会员,实际: " + result.getMessage());
+        verify(taskServiceMock, never()).completeTask(any(), any(), any(), any(), any());
+    }
+
+    // ==================== 控制器传输契约(API_REFERENCE §4.61.2) ====================
+
+    @Test
+    @DisplayName("控制器契约-1:DTO{memberId,photoUrl,content,contentType} 原样转发 Service,响应 Result.success(Map)")
+    void controller_契约_dto字段转发与成功响应() {
+        stubNonMemberOnlyTask();
+        when(taskServiceMock.completeTask(60L, 100L, "http://img/a.png", "打卡内容", "text"))
+                .thenReturn(rewardMap(5, 50, false));
+
+        CompleteTaskDTO dto = new CompleteTaskDTO();
+        dto.setMemberId(100L);
+        dto.setPhotoUrl("http://img/a.png");
+        dto.setContent("打卡内容");
+        dto.setContentType("text");
+        Result<Map<String, Object>> result = controller.completeTask(60L, dto, 200L);
+
+        assertEquals(Integer.valueOf(200), result.getCode());
+        assertEquals("success", result.getMessage());
+        assertEquals(5, result.getData().get("pointsEarned"));
+        verify(taskServiceMock).completeTask(60L, 100L, "http://img/a.png", "打卡内容", "text");
+    }
+
+    @Test
+    @DisplayName("控制器契约-2:DTO 缺省 memberId → 回落 currentMemberId 转发 Service")
+    void controller_契约_memberId缺省回落当前成员() {
+        stubNonMemberOnlyTask();
+        when(taskServiceMock.completeTask(61L, 200L, null, "内容", null)).thenReturn(rewardMap(0, 30, false));
+
+        CompleteTaskDTO dto = new CompleteTaskDTO();
+        dto.setContent("内容");
+        Result<Map<String, Object>> result = controller.completeTask(61L, dto, 200L);
+
+        assertEquals(Integer.valueOf(200), result.getCode());
+        verify(taskServiceMock).completeTask(61L, 200L, null, "内容", null);
+    }
+
+    @Test
+    @DisplayName("控制器契约-3:Service 返回 null → Result.error 明确提示(杜绝静默失败)")
+    void controller_契约_service返回null_错误提示() {
+        stubNonMemberOnlyTask();
+        when(taskServiceMock.completeTask(62L, 100L, null, null, null)).thenReturn(null);
+
+        CompleteTaskDTO dto = new CompleteTaskDTO();
+        dto.setMemberId(100L);
+        Result<Map<String, Object>> result = controller.completeTask(62L, dto, 200L);
+
+        assertEquals(Integer.valueOf(500), result.getCode());
+        assertEquals("任务不存在、无权完成或状态已变更", result.getMessage());
+    }
+
+    // ==================== 辅助构造 ====================
+
+    private Task task(Long id, Long memberId, String status, Integer needReview, Date deadline) {
+        Task t = new Task();
+        t.setId(id);
+        t.setFamilyId(1L);
+        t.setFamilyMemberId(memberId);
+        t.setTitle("睡前阅读");
+        t.setPoints(10);
+        t.setStatus(status);
+        t.setNeedReview(needReview);
+        t.setDeadline(deadline);
+        t.setCategory("学习类");
+        t.setCreatedAt(new Date());
+        return t;
+    }
+
+    private FamilyMember member(Long id, Integer totalPoints, Integer penaltyEnabled) {
+        FamilyMember m = new FamilyMember();
+        m.setId(id);
+        m.setUserId(1L);
+        m.setTotalPoints(totalPoints);
+        m.setPenaltyEnabled(penaltyEnabled);
+        return m;
+    }
+
+    private Map<String, Object> rewardMap(int points, int balance, boolean needReview) {
+        Map<String, Object> m = new HashMap<>();
+        m.put("pointsEarned", points);
+        m.put("newBalance", balance);
+        m.put("needReview", needReview);
+        return m;
+    }
+
+    /** 让门禁(checkMemberOnly)放行:返回非会员任务的 stub(memberOnly != 1) */
+    private void stubNonMemberOnlyTask() {
+        when(taskMapper.selectById(anyLong())).thenAnswer(inv -> {
+            Task t = new Task();
+            t.setId(inv.getArgument(0));
+            t.setFamilyId(1L);
+            t.setMemberOnly(0);
+            return t;
+        });
+    }
+
+    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);
+    }
+}