Pārlūkot izejas kodu

test(tdd): S-cfc-31 sleep.config.tags 契约级单测 (21绿/1红 blank-key 缺陷) 闭环 RED->绿

Xiaogang Liao 6 dienas atpakaļ
vecāks
revīzija
f11062b42

+ 282 - 0
cfc-backend/src/test/java/com/etotem/cfc/controller/HealthSleepControllerContractTest.java

@@ -0,0 +1,282 @@
+package com.etotem.cfc.controller;
+
+import com.etotem.cfc.common.Result;
+import com.etotem.cfc.entity.FamilyMember;
+import com.etotem.cfc.entity.HealthSleepRecord;
+import com.etotem.cfc.entity.Task;
+import com.etotem.cfc.mapper.FamilyMemberMapper;
+import com.etotem.cfc.mapper.HealthSleepRecordMapper;
+import com.etotem.cfc.mapper.TaskMapper;
+import com.etotem.cfc.service.ProfileComputeService;
+import com.etotem.cfc.service.TaskService;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.mockito.ArgumentCaptor;
+import org.springframework.test.util.ReflectionTestUtils;
+
+import java.util.Collections;
+import java.util.Date;
+import java.util.HashMap;
+import java.util.List;
+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.any;
+import static org.mockito.ArgumentMatchers.anyInt;
+import static org.mockito.ArgumentMatchers.anyLong;
+import static org.mockito.ArgumentMatchers.eq;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.never;
+import static org.mockito.Mockito.timeout;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+
+/**
+ * S-cfc-31 · sleep.config.tags —— {@link HealthSleepController} 契约级单测(RED 阶段补齐)。
+ *
+ * <p>设计溯源:{@code HealthSleepController}(/api/health/sleep/list|create,已合入 origin/cfclub)
+ * + {@code sys_config} 机制 + {@code AdminGuideConfigController} get/save 模式
+ * (需求变更 C:睡眠情况选项由后台配置驱动)。契约以「已合入 origin/cfclub 的接口契约」为准。
+ *
+ * <p>覆盖:正常路径 + 关键边界 + 任务回写钩子(每日进度型 / 一次性 / 无 taskId / 成员归属修正)
+ * + 画像重算触发。纯单元测试:直接调用 Controller + Mockito mock mapper/service,
+ * 不依赖数据库/微信/外部服务。
+ */
+public class HealthSleepControllerContractTest {
+
+    private HealthSleepController controller;
+    private HealthSleepRecordMapper healthSleepRecordMapper;
+    private TaskService taskService;
+    private TaskMapper taskMapper;
+    private FamilyMemberMapper familyMemberMapper;
+    private ProfileComputeService profileComputeService;
+
+    @BeforeEach
+    public void setUp() {
+        controller = new HealthSleepController();
+        healthSleepRecordMapper = mock(HealthSleepRecordMapper.class);
+        taskService = mock(TaskService.class);
+        taskMapper = mock(TaskMapper.class);
+        familyMemberMapper = mock(FamilyMemberMapper.class);
+        profileComputeService = mock(ProfileComputeService.class);
+        ReflectionTestUtils.setField(controller, "healthSleepRecordMapper", healthSleepRecordMapper);
+        ReflectionTestUtils.setField(controller, "taskService", taskService);
+        ReflectionTestUtils.setField(controller, "taskMapper", taskMapper);
+        ReflectionTestUtils.setField(controller, "familyMemberMapper", familyMemberMapper);
+        ReflectionTestUtils.setField(controller, "profileComputeService", profileComputeService);
+    }
+
+    // ====================================================================
+    // POST /api/health/sleep/list —— 正常路径 + 关键边界
+    // ====================================================================
+
+    @Test
+    public void list_byParamsMemberId_returnsRecords() {
+        HealthSleepRecord r = new HealthSleepRecord();
+        r.setId(1L);
+        r.setMemberId(10L);
+        r.setRemark("入睡困难,正常");
+        when(healthSleepRecordMapper.selectList(any())).thenReturn(Collections.singletonList(r));
+        Map<String, Object> params = new HashMap<>();
+        params.put("memberId", "10");
+
+        Result<List<HealthSleepRecord>> res = controller.list(params, null);
+
+        assertEquals(200, res.getCode());
+        assertEquals(1, res.getData().size());
+        assertEquals(10L, res.getData().get(0).getMemberId());
+        assertEquals("入睡困难,正常", res.getData().get(0).getRemark(), "存储的 remark(含所选睡眠标签)应原样随记录返回");
+        verify(healthSleepRecordMapper).selectList(any());
+    }
+
+    @Test
+    public void list_memberIdFallsBackToCurrentMemberAttribute() {
+        when(healthSleepRecordMapper.selectList(any())).thenReturn(Collections.emptyList());
+
+        Result<List<HealthSleepRecord>> res = controller.list(new HashMap<>(), 10L);
+
+        assertEquals(200, res.getCode());
+        assertNotNull(res.getData());
+        verify(healthSleepRecordMapper).selectList(any());
+    }
+
+    @Test
+    public void list_noMemberIdAnywhere_returnsError() {
+        Result<List<HealthSleepRecord>> res = controller.list(new HashMap<>(), null);
+
+        assertEquals(500, res.getCode(), "无 memberId 应返回错误而非成功");
+        assertTrue(res.getMessage().contains("memberId"), "message 应指明缺失的 memberId");
+        verify(healthSleepRecordMapper, never()).selectList(any());
+    }
+
+    // ====================================================================
+    // POST /api/health/sleep/create —— 正常路径
+    // ====================================================================
+
+    @Test
+    public void create_validRecord_noTaskId_persistsAndAccumulatesDailyProgress() {
+        HealthSleepRecord rec = new HealthSleepRecord();
+        rec.setMemberId(10L);
+        rec.setSleepTime(new Date());
+        rec.setWakeTime(new Date());
+        rec.setRemark("入睡困难,正常");
+        FamilyMember fm = new FamilyMember();
+        fm.setId(10L);
+        fm.setUserId(100L);
+        when(familyMemberMapper.selectById(10L)).thenReturn(fm);
+
+        Result<HealthSleepRecord> res = controller.create(rec, 10L);
+
+        assertEquals(200, res.getCode());
+        assertEquals(10L, res.getData().getMemberId());
+        assertNotNull(rec.getCreatedAt(), "create 必须写入 createdAt");
+        ArgumentCaptor<HealthSleepRecord> captor = ArgumentCaptor.forClass(HealthSleepRecord.class);
+        verify(healthSleepRecordMapper).insert(captor.capture());
+        assertEquals("入睡困难,正常", captor.getValue().getRemark(), "记录 remark(含所选睡眠标签)必须原样落库");
+        // 无 taskId → 累计今日每日睡眠打卡任务进度
+        verify(taskService).updateProgress(100L, 10L, "SLEEP", 1);
+        // 触发画像重算(fire-and-forget 线程)
+        verify(profileComputeService, timeout(2000)).computeAndSave(10L);
+    }
+
+    @Test
+    public void create_memberIdFromAttribute_whenBodyOmitsIt() {
+        HealthSleepRecord rec = new HealthSleepRecord();
+        rec.setSleepTime(new Date());
+        rec.setWakeTime(new Date());
+        FamilyMember fm = new FamilyMember();
+        fm.setId(7L);
+        fm.setUserId(70L);
+        when(familyMemberMapper.selectById(7L)).thenReturn(fm);
+
+        Result<HealthSleepRecord> res = controller.create(rec, 7L);
+
+        assertEquals(200, res.getCode());
+        assertEquals(7L, rec.getMemberId(), "body 缺 memberId 时应取 currentMemberId 属性");
+        verify(taskService).updateProgress(70L, 7L, "SLEEP", 1);
+    }
+
+    @Test
+    public void create_taskId_oneTimeTask_completesTask() {
+        HealthSleepRecord rec = new HealthSleepRecord();
+        rec.setMemberId(10L);
+        rec.setSleepTime(new Date());
+        rec.setWakeTime(new Date());
+        rec.setTaskId(5L);
+        Task linkedTask = new Task();
+        linkedTask.setId(5L);
+        linkedTask.setFamilyMemberId(10L);
+        linkedTask.setIsDailyProgress(0); // 一次性任务
+        when(taskMapper.selectById(5L)).thenReturn(linkedTask);
+
+        Result<HealthSleepRecord> res = controller.create(rec, 10L);
+
+        assertEquals(200, res.getCode());
+        verify(taskService).completeTask(5L, 10L, "", "睡眠打卡", "text");
+        verify(taskService, never()).updateCheckinTaskProgress(anyLong(), anyLong(), anyLong(), anyInt());
+    }
+
+    @Test
+    public void create_taskId_dailyProgressTask_accumulatesTaskProgress() {
+        HealthSleepRecord rec = new HealthSleepRecord();
+        rec.setMemberId(10L);
+        rec.setSleepTime(new Date());
+        rec.setWakeTime(new Date());
+        rec.setTaskId(5L);
+        Task linkedTask = new Task();
+        linkedTask.setId(5L);
+        linkedTask.setFamilyMemberId(10L);
+        linkedTask.setIsDailyProgress(1); // 每日进度型
+        FamilyMember fm = new FamilyMember();
+        fm.setId(10L);
+        fm.setUserId(100L);
+        when(taskMapper.selectById(5L)).thenReturn(linkedTask);
+        when(familyMemberMapper.selectById(10L)).thenReturn(fm);
+
+        Result<HealthSleepRecord> res = controller.create(rec, 10L);
+
+        assertEquals(200, res.getCode());
+        verify(taskService).updateCheckinTaskProgress(5L, 100L, 10L, 1);
+        verify(taskService, never()).completeTask(anyLong(), anyLong(), any(), any(), any());
+    }
+
+    @Test
+    public void create_memberMismatchWithLinkedTask_correctsMemberOwnership() {
+        HealthSleepRecord rec = new HealthSleepRecord();
+        rec.setMemberId(99L); // 与任务归属不符(家庭主账号切成员视角场景)
+        rec.setSleepTime(new Date());
+        rec.setWakeTime(new Date());
+        rec.setTaskId(5L);
+        Task linkedTask = new Task();
+        linkedTask.setId(5L);
+        linkedTask.setFamilyMemberId(10L);
+        linkedTask.setIsDailyProgress(0);
+        when(taskMapper.selectById(5L)).thenReturn(linkedTask);
+
+        Result<HealthSleepRecord> res = controller.create(rec, 99L);
+
+        assertEquals(200, res.getCode());
+        assertEquals(10L, rec.getMemberId(), "以任务归属为准修正记录成员,避免打卡记录与任务串号");
+        verify(taskService).completeTask(5L, 10L, "", "睡眠打卡", "text");
+    }
+
+    @Test
+    public void create_taskWritebackFailure_doesNotBreakMainFlow() {
+        HealthSleepRecord rec = new HealthSleepRecord();
+        rec.setMemberId(10L);
+        rec.setSleepTime(new Date());
+        rec.setWakeTime(new Date());
+        rec.setTaskId(5L); // 必须携带 taskId,才能进入任务回写分支以验证其故障隔离
+        when(taskMapper.selectById(5L)).thenThrow(new RuntimeException("taskMapper 故障"));
+
+        Result<HealthSleepRecord> res = controller.create(rec, 10L);
+
+        assertEquals(200, res.getCode(), "任务回写失败不应影响打卡主流程");
+        verify(healthSleepRecordMapper).insert(rec);
+    }
+
+    // ====================================================================
+    // POST /api/health/sleep/create —— 关键边界(校验)
+    // ====================================================================
+
+    @Test
+    public void create_noMemberIdAndNoAttribute_returnsError() {
+        HealthSleepRecord rec = new HealthSleepRecord();
+        rec.setSleepTime(new Date());
+        rec.setWakeTime(new Date());
+
+        Result<HealthSleepRecord> res = controller.create(rec, null);
+
+        assertEquals(500, res.getCode());
+        assertTrue(res.getMessage().contains("memberId"));
+        verify(healthSleepRecordMapper, never()).insert(any());
+    }
+
+    @Test
+    public void create_missingSleepTime_returnsError() {
+        HealthSleepRecord rec = new HealthSleepRecord();
+        rec.setMemberId(10L);
+        rec.setWakeTime(new Date());
+
+        Result<HealthSleepRecord> res = controller.create(rec, 10L);
+
+        assertEquals(500, res.getCode());
+        assertTrue(res.getMessage().contains("入睡时间"));
+        verify(healthSleepRecordMapper, never()).insert(any());
+    }
+
+    @Test
+    public void create_missingWakeTime_returnsError() {
+        HealthSleepRecord rec = new HealthSleepRecord();
+        rec.setMemberId(10L);
+        rec.setSleepTime(new Date());
+
+        Result<HealthSleepRecord> res = controller.create(rec, 10L);
+
+        assertEquals(500, res.getCode());
+        assertTrue(res.getMessage().contains("醒来时间"));
+        verify(healthSleepRecordMapper, never()).insert(any());
+    }
+}

+ 180 - 0
cfc-backend/src/test/java/com/etotem/cfc/controller/config/SleepConfigTagsMechanismContractTest.java

@@ -0,0 +1,180 @@
+package com.etotem.cfc.controller.config;
+
+import com.etotem.cfc.common.Result;
+import com.etotem.cfc.service.SysConfigService;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.mockito.ArgumentCaptor;
+import org.springframework.test.util.ReflectionTestUtils;
+
+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.assertNull;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.ArgumentMatchers.eq;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.never;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+
+/**
+ * S-cfc-31 · sleep.config.tags —— 后台配置读取/维护机制契约级单测(RED 阶段补齐)。
+ *
+ * <p>设计溯源:{@code PublicConfigController /api/config/public/value}(小程序端拉取
+ * {@code sleep.quality.tags} 配置值)+ {@code AdminGuideConfigController} get/save 模式
+ * (管理端维护配置的参考实现,见需求变更 C「后台维护睡眠情况选项」)。契约以
+ * 「已合入 origin/cfclub 的接口契约」为准。
+ *
+ * <p>覆盖:正常路径(配置值读取 / 解析返回 / 保存落库)+ 关键边界(key 缺失、
+ * 配置未配置返回 null、configValue 空拒绝、body 空拒绝)。纯单元测试:直接调用
+ * Controller + Mockito mock {@code SysConfigService},不依赖数据库/微信/外部服务。
+ */
+public class SleepConfigTagsMechanismContractTest {
+
+    private PublicConfigController publicConfigController;
+    private com.etotem.cfc.controller.admin.AdminGuideConfigController adminGuideConfigController;
+    private SysConfigService sysConfigService;
+
+    @BeforeEach
+    public void setUp() {
+        publicConfigController = new PublicConfigController();
+        adminGuideConfigController = new com.etotem.cfc.controller.admin.AdminGuideConfigController();
+        sysConfigService = mock(SysConfigService.class);
+        ReflectionTestUtils.setField(publicConfigController, "sysConfigService", sysConfigService);
+        ReflectionTestUtils.setField(adminGuideConfigController, "sysConfigService", sysConfigService);
+    }
+
+    // ====================================================================
+    // POST /api/config/public/value —— 小程序端拉取 sleep.quality.tags 配置值
+    // ====================================================================
+
+    @Test
+    public void publicValue_keyPresent_returnsStoredConfigValue() {
+        when(sysConfigService.getValue("sleep.quality.tags"))
+                .thenReturn("[\"入睡困难\",\"夜醒\",\"多梦\",\"打鼾\",\"早醒\",\"赖床\",\"熬夜\",\"午睡过长\",\"正常\"]");
+        Map<String, String> params = new HashMap<>();
+        params.put("key", "sleep.quality.tags");
+
+        Result<String> res = publicConfigController.getValue(params);
+
+        assertEquals(200, res.getCode());
+        assertNotNull(res.getData());
+        assertTrue(res.getData().contains("正常"), "默认 9 项配置必须包含「正常」");
+        assertTrue(res.getData().contains("午睡过长"), "默认配置应保留原 8 项");
+    }
+
+    @Test
+    public void publicValue_unconfiguredKey_returnsNullData_contractForFrontendFallback() {
+        // 配置缺失:返回 success(null),前端据此回退默认 9 项(不报错)
+        when(sysConfigService.getValue("sleep.quality.tags")).thenReturn(null);
+        Map<String, String> params = new HashMap<>();
+        params.put("key", "sleep.quality.tags");
+
+        Result<String> res = publicConfigController.getValue(params);
+
+        assertEquals(200, res.getCode(), "配置缺失不得报错(前端回退默认)");
+        assertNull(res.getData());
+    }
+
+    @Test
+    public void publicValue_missingKey_returnsError() {
+        Result<String> res = publicConfigController.getValue(new HashMap<>());
+
+        assertEquals(500, res.getCode());
+        assertTrue(res.getMessage().contains("key"), "message 应指明缺失的 key");
+        verify(sysConfigService, never()).getValue(any());
+    }
+
+    @Test
+    public void publicValue_blankKey_returnsError() {
+        Map<String, String> params = new HashMap<>();
+        params.put("key", "   ");
+
+        Result<String> res = publicConfigController.getValue(params);
+
+        assertEquals(500, res.getCode());
+        assertTrue(res.getMessage().contains("key"));
+        verify(sysConfigService, never()).getValue(any());
+    }
+
+    // ====================================================================
+    // POST /api/admin/guide-config/get —— 管理端读取配置(未配置→null,前端用默认)
+    // ====================================================================
+
+    @Test
+    public void guideConfigGet_notConfigured_returnsNullData() {
+        when(sysConfigService.getValue("onboarding_guide_config")).thenReturn(null);
+
+        Result<Map<String, Object>> res = adminGuideConfigController.get();
+
+        assertEquals(200, res.getCode());
+        assertNull(res.getData(), "未配置时返回 null,前端使用默认值");
+    }
+
+    @Test
+    public void guideConfigGet_configured_returnsParsedMap() throws Exception {
+        when(sysConfigService.getValue("onboarding_guide_config"))
+                .thenReturn("{\"show\":true,\"items\":[{\"id\":\"a\",\"text\":\"引导A\"}]}");
+
+        Result<Map<String, Object>> res = adminGuideConfigController.get();
+
+        assertEquals(200, res.getCode());
+        assertNotNull(res.getData());
+        assertEquals(true, res.getData().get("show"));
+    }
+
+    @Test
+    public void guideConfigGet_malformedValue_returnsParseError() {
+        when(sysConfigService.getValue("onboarding_guide_config")).thenReturn("{not-json");
+
+        Result<Map<String, Object>> res = adminGuideConfigController.get();
+
+        assertEquals(500, res.getCode());
+        assertTrue(res.getMessage().contains("解析失败"), "非法 JSON 应给出解析失败提示");
+    }
+
+    // ====================================================================
+    // POST /api/admin/guide-config/save —— 管理端保存配置(写语义)
+    // ====================================================================
+
+    @Test
+    public void guideConfigSave_validConfigValue_persists() {
+        Map<String, String> body = new HashMap<>();
+        body.put("configValue", "{\"show\":true}");
+        when(sysConfigService.update(any(), any(), any())).thenReturn(Result.success("ok"));
+
+        Result<String> res = adminGuideConfigController.save(body);
+
+        assertEquals(200, res.getCode());
+        ArgumentCaptor<String> keyCaptor = ArgumentCaptor.forClass(String.class);
+        ArgumentCaptor<String> valueCaptor = ArgumentCaptor.forClass(String.class);
+        verify(sysConfigService).update(keyCaptor.capture(), valueCaptor.capture(), eq("首页引导浮层配置"));
+        assertEquals("onboarding_guide_config", keyCaptor.getValue());
+        assertEquals("{\"show\":true}", valueCaptor.getValue());
+    }
+
+    @Test
+    public void guideConfigSave_emptyConfigValue_returnsError() {
+        Map<String, String> body = new HashMap<>();
+        body.put("configValue", "");
+
+        Result<String> res = adminGuideConfigController.save(body);
+
+        assertEquals(500, res.getCode());
+        assertTrue(res.getMessage().contains("configValue"), "message 应指明缺失的 configValue");
+        verify(sysConfigService, never()).update(any(), any(), any());
+    }
+
+    @Test
+    public void guideConfigSave_nullBody_returnsError() {
+        Result<String> res = adminGuideConfigController.save(null);
+
+        assertEquals(500, res.getCode());
+        assertTrue(res.getMessage().contains("configValue"));
+        verify(sysConfigService, never()).update(any(), any(), any());
+    }
+}