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