|
|
@@ -0,0 +1,547 @@
|
|
|
+package com.etotem.cfc.unit;
|
|
|
+
|
|
|
+import com.baomidou.mybatisplus.core.conditions.Wrapper;
|
|
|
+import com.etotem.cfc.dto.CreateTaskDTO;
|
|
|
+import com.etotem.cfc.entity.Task;
|
|
|
+import com.etotem.cfc.entity.User;
|
|
|
+import com.etotem.cfc.mapper.FamilyMemberMapper;
|
|
|
+import com.etotem.cfc.mapper.MiniGameMapper;
|
|
|
+import com.etotem.cfc.mapper.SurveyTemplateMapper;
|
|
|
+import com.etotem.cfc.mapper.TaskMapper;
|
|
|
+import com.etotem.cfc.service.TaskService;
|
|
|
+import com.etotem.cfc.service.UserService;
|
|
|
+import org.junit.jupiter.api.DisplayName;
|
|
|
+import org.junit.jupiter.api.Test;
|
|
|
+
|
|
|
+import java.lang.reflect.Field;
|
|
|
+import java.lang.reflect.Method;
|
|
|
+import java.util.ArrayList;
|
|
|
+import java.util.Calendar;
|
|
|
+import java.util.Date;
|
|
|
+import java.util.List;
|
|
|
+import java.util.Set;
|
|
|
+import java.util.concurrent.atomic.AtomicReference;
|
|
|
+
|
|
|
+import static org.junit.jupiter.api.Assertions.assertDoesNotThrow;
|
|
|
+import static org.junit.jupiter.api.Assertions.assertEquals;
|
|
|
+import static org.junit.jupiter.api.Assertions.assertNotNull;
|
|
|
+import static org.junit.jupiter.api.Assertions.assertThrows;
|
|
|
+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.never;
|
|
|
+import static org.mockito.Mockito.verify;
|
|
|
+import static org.mockito.Mockito.when;
|
|
|
+
|
|
|
+/**
|
|
|
+ * 契约级单测:任务频率/周期配置——自定义周期、跨月/跨年、暂停/恢复(能力键 {@code task.frequency.config},单元 T 任务与激励)。
|
|
|
+ *
|
|
|
+ * <p><b>需求/设计锚点</b></p>
|
|
|
+ * <ul>
|
|
|
+ * <li>需求:{@code tests/user-scenarios/应用场景总览.md} <b>SC-P-12</b> ——
|
|
|
+ * 目标:设日/周/<b>自定义周期</b>;流程:配频率 → 按周期自动生成实例;
|
|
|
+ * 边界:周期计算<b>跨月/跨年正确</b>;<b>暂停/恢复</b>。模块:{@code TaskController}。</li>
|
|
|
+ * <li>设计:{@code docs/superpowers/api/API_REFERENCE.md} §4.3 任务 ——
|
|
|
+ * {@code POST /api/tasks/create}(actionType+actionConfig 声明任务配置)、
|
|
|
+ * {@code POST /api/tasks/{id}/start}(两阶段入口);创建入参 {@code frequency} 现有合法值
|
|
|
+ * {@code none/daily/weekly/monthly/every_other_day/hourly}({@link TaskService#createTask})。</li>
|
|
|
+ * <li>现状核查(2026-11-14,origin cfclub 7da12b6f4):
|
|
|
+ * {@code RepeatTaskGenerator} 已实现 fixed 频率(hourly/daily/every_other_day/weekly/monthly)
|
|
|
+ * 实例生成,但 <b>① 无自定义周期配置(frequency=custom + 周期参数)② 月频 31 日在短月无回退
|
|
|
+ * ({@code templateDay == todayDay} 严格相等)③ 无暂停/恢复控制</b>——三者均为 SC-P-12 契约空白。</li>
|
|
|
+ * </ul>
|
|
|
+ *
|
|
|
+ * <p><b>宿主契约(GREEN 按此落地)</b></p>
|
|
|
+ * <ul>
|
|
|
+ * <li>{@code CreateTaskDTO.cycleInterval}(Integer,≥1)/ {@code cycleUnit}(String:day/week/month)
|
|
|
+ * —— 创建入口声明自定义周期(§4.3 /api/tasks/create 频率配置域扩展,不新增路由);</li>
|
|
|
+ * <li>{@code Task.cycleInterval} / {@code Task.cycleUnit} / {@code Task.scheduleStatus}
|
|
|
+ * ("active"|"paused",默认 active)—— 周期配置与暂停态落库;</li>
|
|
|
+ * <li>{@code TaskService.VALID_FREQUENCIES} 应含 {@code "custom"};异常流:cycleInterval ≤ 0 或缺失 → 400;
|
|
|
+ * cycleInterval 缺省默认每日(cycleUnit=day, interval=1);</li>
|
|
|
+ * <li>{@code com.etotem.cfc.util.TaskCycleCalculator}(纯计算、无 Spring/DB):
|
|
|
+ * {@code Date nextOccurrence(Date cycleStart, String cycleUnit, int interval, Date from)}
|
|
|
+ * —— 严格晚于 from 的下一次发生日(00:00:00,本地时区;按月遇短月回退至当月最后一天,跨年由周期推进保证);
|
|
|
+ * {@code Date resolveMonthlyDate(int templateDay, int year, int month)} —— month=1..12,短月回退月末;</li>
|
|
|
+ * <li>{@code RepeatTaskGenerator}:注入 {@code TaskCycleCalculator};新增 frequency=custom 分派;
|
|
|
+ * 提供 {@code generateDailyTasks(Date now)} 重载作为可注入时钟的测试缝;生成前校验模板
|
|
|
+ * {@code scheduleStatus != "paused"}(暂停中的模板不生成实例);</li>
|
|
|
+ * <li>{@code TaskService.pauseSchedule(Long taskId, Long userId)} / {@code resumeSchedule(Long taskId, Long userId)}
|
|
|
+ * —— 返回 Boolean;任务不存在或不属于该用户家庭 → IllegalArgumentException(同 SC-P-10 越权口径);</li>
|
|
|
+ * <li>{@code TaskController}:{@code @PostMapping("/{id}/pause")}(pauseTask)、
|
|
|
+ * {@code @PostMapping("/{id}/resume")}(resumeTask),统一 POST 规范。</li>
|
|
|
+ * </ul>
|
|
|
+ *
|
|
|
+ * <p><b>测试范围</b>:纯契约级单测(反射/Mockito),无 Spring / 无 DB / 无外部服务。
|
|
|
+ * 宿主类缺失、字段/方法缺失、默认值未实现均以 {@code fail(...)} 断言暴露(红)。</p>
|
|
|
+ *
|
|
|
+ * <p><b>RED 预期</b>:上述宿主契约当前全部未落地,本类用例在 RED 阶段应全部失败(红);
|
|
|
+ * 失败断言原文带 {@code [task.frequency.config]} 前缀,清晰可贴。</p>
|
|
|
+ *
|
|
|
+ * <p><b>权限流</b>:暂停/恢复为家长对模板的调度控制,存在家庭作用域 → 权限流适用(F1 跨家庭拒绝)。</p>
|
|
|
+ */
|
|
|
+class TaskFrequencyConfigContractTest {
|
|
|
+
|
|
|
+ /** 自建周期计算宿主(当前未实现)。 */
|
|
|
+ private static final String CYCLE_CALCULATOR = "com.etotem.cfc.util.TaskCycleCalculator";
|
|
|
+
|
|
|
+ /** 重复任务生成器(已存在宿主,本契约要求其接入自定义周期与暂停控制)。 */
|
|
|
+ private static final String GENERATOR = "com.etotem.cfc.task.RepeatTaskGenerator";
|
|
|
+
|
|
|
+ // ==================== 正常流-1:自定义周期配置承载(create 声明 → 落库) ====================
|
|
|
+
|
|
|
+ @Test
|
|
|
+ @DisplayName("正常流-1a:CreateTaskDTO 应声明自定义周期入参 cycleInterval/cycleUnit(§4.3 /api/tasks/create)")
|
|
|
+ void createTaskDTO_declaresCustomCycleParams() throws Exception {
|
|
|
+ assertHasField(CreateTaskDTO.class, "cycleInterval",
|
|
|
+ "[task.frequency.config] CreateTaskDTO 缺 cycleInterval 字段(Integer≥1):"
|
|
|
+ + "SC-P-12 要求创建任务时声明自定义周期,经 §4.3 /api/tasks/create 频率配置域提交。");
|
|
|
+ assertHasField(CreateTaskDTO.class, "cycleUnit",
|
|
|
+ "[task.frequency.config] CreateTaskDTO 缺 cycleUnit 字段(day/week/month):"
|
|
|
+ + "SC-P-12 要求自定义周期按单位推进(每N天/周/月)。");
|
|
|
+ }
|
|
|
+
|
|
|
+ @Test
|
|
|
+ @DisplayName("正常流-1b:Task 实体应承载周期配置与暂停态(cycleInterval/cycleUnit/scheduleStatus)")
|
|
|
+ void taskEntity_carriesCycleConfigAndScheduleStatus() throws Exception {
|
|
|
+ assertHasField(Task.class, "cycleInterval",
|
|
|
+ "[task.frequency.config] Task 实体缺 cycleInterval 字段:自定义周期配置应落库供实例生成使用。");
|
|
|
+ assertHasField(Task.class, "cycleUnit",
|
|
|
+ "[task.frequency.config] Task 实体缺 cycleUnit 字段:自定义周期单位(day/week/month)应落库。");
|
|
|
+ assertHasField(Task.class, "scheduleStatus",
|
|
|
+ "[task.frequency.config] Task 实体缺 scheduleStatus 字段(active/paused,默认 active):"
|
|
|
+ + "SC-P-12 暂停/恢复需要模板级调度状态,暂停中的模板不得再生成实例。");
|
|
|
+ }
|
|
|
+
|
|
|
+ @Test
|
|
|
+ @DisplayName("正常流-1c:频率枚举必须支持 custom(自定义周期)")
|
|
|
+ void taskService_frequencyEnum_supportsCustom() throws Exception {
|
|
|
+ Field f = TaskService.class.getDeclaredField("VALID_FREQUENCIES");
|
|
|
+ f.setAccessible(true);
|
|
|
+ @SuppressWarnings("unchecked")
|
|
|
+ Set<String> freqs = (Set<String>) f.get(null);
|
|
|
+ assertTrue(freqs.contains("custom"),
|
|
|
+ "[task.frequency.config] 频率枚举缺 custom:SC-P-12 目标为「设日/周/自定义周期」,"
|
|
|
+ + "自定义周期必须可作为频率提交,当前合法值=" + freqs + "。");
|
|
|
+ }
|
|
|
+
|
|
|
+ // ==================== 正常流-2:按自定义周期自动生成实例 ====================
|
|
|
+
|
|
|
+ @Test
|
|
|
+ @DisplayName("正常流-2a:纯周期计算宿主 TaskCycleCalculator 应存在(跨月/跨年计算归属)")
|
|
|
+ void taskCycleCalculator_hostExists() {
|
|
|
+ assertNotNull(loadClass(CYCLE_CALCULATOR),
|
|
|
+ "[task.frequency.config] TaskCycleCalculator 未实现:SC-P-12 边界「周期计算跨月/跨年正确」"
|
|
|
+ + "需要有可独立验证的纯周期计算组件(无 Spring/DB)。");
|
|
|
+ }
|
|
|
+
|
|
|
+ @Test
|
|
|
+ @DisplayName("正常流-2b:TaskCycleCalculator 应提供 nextOccurrence(下一次发生日,跨月/跨年)")
|
|
|
+ void taskCycleCalculator_nextOccurrenceMethod() {
|
|
|
+ Class<?> calc = requireClass(CYCLE_CALCULATOR,
|
|
|
+ "[task.frequency.config] TaskCycleCalculator 未实现,无法验证 nextOccurrence(SC-P-12 按周期生成实例)。");
|
|
|
+ assertHasMethod(calc, "nextOccurrence",
|
|
|
+ "[task.frequency.config] TaskCycleCalculator 应提供 nextOccurrence(Date cycleStart, "
|
|
|
+ + "String cycleUnit, int interval, Date from),返回严格晚于 from 的下一次发生日。",
|
|
|
+ Date.class, String.class, int.class, Date.class);
|
|
|
+ }
|
|
|
+
|
|
|
+ @Test
|
|
|
+ @DisplayName("正常流-2c:TaskCycleCalculator 应提供 resolveMonthlyDate(月频短月回退)")
|
|
|
+ void taskCycleCalculator_resolveMonthlyDateMethod() {
|
|
|
+ Class<?> calc = requireClass(CYCLE_CALCULATOR,
|
|
|
+ "[task.frequency.config] TaskCycleCalculator 未实现,无法验证 resolveMonthlyDate(SC-P-12 跨月正确)。");
|
|
|
+ assertHasMethod(calc, "resolveMonthlyDate",
|
|
|
+ "[task.frequency.config] TaskCycleCalculator 应提供 resolveMonthlyDate(int templateDay, "
|
|
|
+ + "int year, int month)(month=1..12):每月31日在2月等短月应回退到当月最后一天。",
|
|
|
+ int.class, int.class, int.class);
|
|
|
+ }
|
|
|
+
|
|
|
+ @Test
|
|
|
+ @DisplayName("正常流-2d:每天周期跨年——2025-12-31 的次日必须为 2026-01-01")
|
|
|
+ void nextOccurrence_dailyCrossesYear() throws Exception {
|
|
|
+ Object calc = contractHost(CYCLE_CALCULATOR);
|
|
|
+ Date next = callDate(calc, "nextOccurrence", date(2025, 11, 31), "day", 1, date(2025, 11, 31));
|
|
|
+ assertEquals(date(2026, 0, 1), next,
|
|
|
+ "[task.frequency.config] 每天周期跨年错误:2025-12-31 的次日应为 2026-01-01,实际=" + next + "。");
|
|
|
+ }
|
|
|
+
|
|
|
+ @Test
|
|
|
+ @DisplayName("正常流-2e:每周周期跨年——2025-12-29(周一)的下一个周一必须为 2026-01-05")
|
|
|
+ void nextOccurrence_weeklyCrossesYear() throws Exception {
|
|
|
+ Object calc = contractHost(CYCLE_CALCULATOR);
|
|
|
+ Date next = callDate(calc, "nextOccurrence", date(2025, 11, 29), "week", 1, date(2025, 11, 29));
|
|
|
+ assertEquals(date(2026, 0, 5), next,
|
|
|
+ "[task.frequency.config] 每周周期跨年错误:2025-12-29 的下一次应落在 2026-01-05,实际=" + next + "。");
|
|
|
+ }
|
|
|
+
|
|
|
+ @Test
|
|
|
+ @DisplayName("正常流-2f:每月31日遇2月短月——2026-01-31 的下一个月实例必须回退到 2026-02-28")
|
|
|
+ void nextOccurrence_monthlyShortMonthFallsBack() throws Exception {
|
|
|
+ Object calc = contractHost(CYCLE_CALCULATOR);
|
|
|
+ Date next = callDate(calc, "nextOccurrence", date(2026, 0, 31), "month", 1, date(2026, 0, 31));
|
|
|
+ assertEquals(date(2026, 1, 28), next,
|
|
|
+ "[task.frequency.config] 月频短月回退错误:31日模板在2月应落到 2026-02-28 而非不生成,实际=" + next + "。");
|
|
|
+ }
|
|
|
+
|
|
|
+ @Test
|
|
|
+ @DisplayName("正常流-2g:自定义周期 interval=2(隔月)越过短月——2026-01-31 后两月为 2026-03-31")
|
|
|
+ void nextOccurrence_customInterval2SkipsShortMonth() throws Exception {
|
|
|
+ Object calc = contractHost(CYCLE_CALCULATOR);
|
|
|
+ Date next = callDate(calc, "nextOccurrence", date(2026, 0, 31), "month", 2, date(2026, 0, 31));
|
|
|
+ assertEquals(date(2026, 2, 31), next,
|
|
|
+ "[task.frequency.config] 自定义周期(月,2)错误:2026-01-31 之后两月应为 2026-03-31,实际=" + next + "。");
|
|
|
+ }
|
|
|
+
|
|
|
+ @Test
|
|
|
+ @DisplayName("正常流-2h:resolveMonthlyDate 短月/闰年回退正确")
|
|
|
+ void resolveMonthlyDate_shortAndLeapMonths() throws Exception {
|
|
|
+ Object calc = contractHost(CYCLE_CALCULATOR);
|
|
|
+ assertEquals(date(2026, 0, 31), callDate(calc, "resolveMonthlyDate", 31, 2026, 1),
|
|
|
+ "[task.frequency.config] 1月31日应原样返回 1月31日。");
|
|
|
+ assertEquals(date(2026, 1, 28), callDate(calc, "resolveMonthlyDate", 31, 2026, 2),
|
|
|
+ "[task.frequency.config] 2026-02(平年)31日应回退到 2月28日。");
|
|
|
+ assertEquals(date(2028, 1, 29), callDate(calc, "resolveMonthlyDate", 31, 2028, 2),
|
|
|
+ "[task.frequency.config] 2028-02(闰年)31日应回退到 2月29日。");
|
|
|
+ assertEquals(date(2026, 3, 30), callDate(calc, "resolveMonthlyDate", 31, 2026, 4),
|
|
|
+ "[task.frequency.config] 4月31日应回退到 4月30日。");
|
|
|
+ }
|
|
|
+
|
|
|
+ // ==================== 正常流-2i:RepeatTaskGenerator 接入自定义周期 ====================
|
|
|
+
|
|
|
+ @Test
|
|
|
+ @DisplayName("正常流-2i:RepeatTaskGenerator 应注入 TaskCycleCalculator 供自定义周期与月度回退分派")
|
|
|
+ void repeatTaskGenerator_injectsCycleCalculator() {
|
|
|
+ Class<?> gen = requireClass(GENERATOR,
|
|
|
+ "[task.frequency.config] RepeatTaskGenerator 不存在:SC-P-12「按周期自动生成实例」的生成宿主。");
|
|
|
+ boolean injected = false;
|
|
|
+ for (Field f : gen.getDeclaredFields()) {
|
|
|
+ if (CYCLE_CALCULATOR.equals(f.getType().getName())) {
|
|
|
+ injected = true;
|
|
|
+ break;
|
|
|
+ }
|
|
|
+ }
|
|
|
+ assertTrue(injected,
|
|
|
+ "[task.frequency.config] RepeatTaskGenerator 未注入 TaskCycleCalculator:"
|
|
|
+ + "自定义周期与跨月/跨年计算应委托纯计算组件,而非在生成器内手写 Calendar 逻辑。");
|
|
|
+ }
|
|
|
+
|
|
|
+ @Test
|
|
|
+ @DisplayName("正常流-2j:RepeatTaskGenerator 应提供 frequency=custom 分派方法")
|
|
|
+ void repeatTaskGenerator_hasCustomCycleDispatch() {
|
|
|
+ Class<?> gen = requireClass(GENERATOR,
|
|
|
+ "[task.frequency.config] RepeatTaskGenerator 不存在,无法验证 custom 分派。");
|
|
|
+ boolean hasDispatch = false;
|
|
|
+ for (Method m : gen.getDeclaredMethods()) {
|
|
|
+ String name = m.getName().toLowerCase();
|
|
|
+ if (name.contains("custom") || name.contains("cycle")) {
|
|
|
+ hasDispatch = true;
|
|
|
+ break;
|
|
|
+ }
|
|
|
+ }
|
|
|
+ assertTrue(hasDispatch,
|
|
|
+ "[task.frequency.config] RepeatTaskGenerator 缺 frequency=custom 分派:"
|
|
|
+ + "SC-P-12「配频率 → 按周期自动生成实例」应覆盖自定义周期(每N天/周/月)。");
|
|
|
+ }
|
|
|
+
|
|
|
+ @Test
|
|
|
+ @DisplayName("正常流-2k:RepeatTaskGenerator 应提供 generateDailyTasks(Date) 可注入时钟重载(测试缝)")
|
|
|
+ void repeatTaskGenerator_hasDateSeam() {
|
|
|
+ Class<?> gen = requireClass(GENERATOR,
|
|
|
+ "[task.frequency.config] RepeatTaskGenerator 不存在,无法验证测试缝。");
|
|
|
+ assertHasMethod(gen, "generateDailyTasks",
|
|
|
+ "[task.frequency.config] RepeatTaskGenerator 应提供 generateDailyTasks(Date now) 重载"
|
|
|
+ + "(原无参方法委托之):周期实例生成需可注入时钟以验证跨月/跨年/暂停边界。",
|
|
|
+ Date.class);
|
|
|
+ }
|
|
|
+
|
|
|
+ // ==================== 边界流:暂停/恢复 ====================
|
|
|
+
|
|
|
+ @Test
|
|
|
+ @DisplayName("边界流-1:暂停中的模板不得生成实例(generateDailyTasks(Date) 走查)")
|
|
|
+ void repeatTaskGenerator_pausedTemplate_notGenerated() throws Exception {
|
|
|
+ Class<?> gen = requireClass(GENERATOR,
|
|
|
+ "[task.frequency.config] RepeatTaskGenerator 不存在,无法验证暂停跳过行为。");
|
|
|
+ Method seam = findMethod(gen, "generateDailyTasks", Date.class);
|
|
|
+ assertNotNull(seam,
|
|
|
+ "[task.frequency.config] RepeatTaskGenerator 缺 generateDailyTasks(Date) 测试缝"
|
|
|
+ + "(SC-P-12 暂停/恢复行为验证入口)。");
|
|
|
+
|
|
|
+ Object generator = gen.getDeclaredConstructor().newInstance();
|
|
|
+ TaskMapper taskMapper = mock(TaskMapper.class);
|
|
|
+ FamilyMemberMapper familyMemberMapper = mock(FamilyMemberMapper.class);
|
|
|
+ inject(generator, "taskMapper", taskMapper);
|
|
|
+ inject(generator, "familyMemberMapper", familyMemberMapper);
|
|
|
+
|
|
|
+ Task pausedTemplate = pausedTemplate();
|
|
|
+ when(taskMapper.selectList(any(Wrapper.class)))
|
|
|
+ .thenReturn(java.util.Collections.singletonList(pausedTemplate),
|
|
|
+ java.util.Collections.emptyList(), java.util.Collections.emptyList(),
|
|
|
+ java.util.Collections.emptyList(), java.util.Collections.emptyList());
|
|
|
+ when(taskMapper.selectCount(any(Wrapper.class))).thenReturn(0L);
|
|
|
+
|
|
|
+ seam.invoke(generator, date(2026, 10, 14)); // 2026-11-14
|
|
|
+
|
|
|
+ verify(taskMapper, never()).insert(any(Task.class));
|
|
|
+ assertTrue(true, "[task.frequency.config] 暂停中的模板未生成实例(插入未发生)。");
|
|
|
+ }
|
|
|
+
|
|
|
+ @Test
|
|
|
+ @DisplayName("边界流-2:TaskService 应提供暂停/恢复调度入口 pauseSchedule/resumeSchedule")
|
|
|
+ void taskService_hasPauseResumeSchedule() throws Exception {
|
|
|
+ assertHasMethod(TaskService.class, "pauseSchedule",
|
|
|
+ "[task.frequency.config] TaskService 缺 pauseSchedule(Long taskId, Long userId):"
|
|
|
+ + "SC-P-12「暂停」需服务层入口(校验家庭归属后置 scheduleStatus=paused)。",
|
|
|
+ Long.class, Long.class);
|
|
|
+ assertHasMethod(TaskService.class, "resumeSchedule",
|
|
|
+ "[task.frequency.config] TaskService 缺 resumeSchedule(Long taskId, Long userId):"
|
|
|
+ + "SC-P-12「恢复」需服务层入口(置回 active 后重新纳入实例生成)。",
|
|
|
+ Long.class, Long.class);
|
|
|
+ }
|
|
|
+
|
|
|
+ @Test
|
|
|
+ @DisplayName("边界流-3:TaskController 应提供 POST /{id}/pause 与 POST /{id}/resume 端点")
|
|
|
+ void taskController_hasPauseResumeEndpoints() throws Exception {
|
|
|
+ com.etotem.cfc.controller.task.TaskController controller = new com.etotem.cfc.controller.task.TaskController();
|
|
|
+ assertHasMethod(controller.getClass(), "pauseTask",
|
|
|
+ "[task.frequency.config] TaskController 缺 @PostMapping(\"/{id}/pause\") pauseTask 端点:"
|
|
|
+ + "SC-P-12 暂停经任务详情页操作,模块 TaskController。",
|
|
|
+ Long.class);
|
|
|
+ assertHasMethod(controller.getClass(), "resumeTask",
|
|
|
+ "[task.frequency.config] TaskController 缺 @PostMapping(\"/{id}/resume\") resumeTask 端点:"
|
|
|
+ + "SC-P-12 恢复经任务详情页操作。",
|
|
|
+ Long.class);
|
|
|
+ }
|
|
|
+
|
|
|
+ // ==================== 异常流:非法自定义周期参数 ====================
|
|
|
+
|
|
|
+ @Test
|
|
|
+ @DisplayName("异常流-1:frequency=custom 且 cycleInterval≤0 → 400 周期参数错误,不落库")
|
|
|
+ void createTask_customInvalidInterval_rejected() throws Exception {
|
|
|
+ Harness h = harness();
|
|
|
+ CreateTaskDTO dto = customCycleDto(0);
|
|
|
+ IllegalArgumentException e = assertThrows(IllegalArgumentException.class,
|
|
|
+ () -> h.service.createTask(1L, dto));
|
|
|
+ assertTrue(e.getMessage().contains("周期"),
|
|
|
+ "[task.frequency.config] custom 周期且 interval≤0 应被拒绝并提示周期参数错误,"
|
|
|
+ + "实际提示: " + e.getMessage());
|
|
|
+ verify(h.taskMapper, never()).insert(any(Task.class));
|
|
|
+ }
|
|
|
+
|
|
|
+ @Test
|
|
|
+ @DisplayName("异常流-2:frequency=custom 缺省周期参数 → 默认每日(cycleUnit=day, cycleInterval=1)落库")
|
|
|
+ void createTask_customNoInterval_defaultsDaily() throws Exception {
|
|
|
+ Harness h = harness();
|
|
|
+ CreateTaskDTO dto = customCycleDto(null);
|
|
|
+ AtomicReference<Task> created = new AtomicReference<>();
|
|
|
+ assertDoesNotThrow(() -> {
|
|
|
+ h.service.createTask(1L, dto);
|
|
|
+ created.set(h.inserted.get(0));
|
|
|
+ }, "[task.frequency.config] frequency=custom 缺省周期参数应可创建(默认每日),当前被拒绝。");
|
|
|
+ assertEquals("day", reflectField(created.get(), "cycleUnit"),
|
|
|
+ "[task.frequency.config] custom 缺省周期应默认 cycleUnit=day。");
|
|
|
+ assertEquals(1, reflectField(created.get(), "cycleInterval"),
|
|
|
+ "[task.frequency.config] custom 缺省周期应默认 cycleInterval=1(每日一次)。");
|
|
|
+ }
|
|
|
+
|
|
|
+ // ==================== 权限流:暂停/恢复家庭作用域 ====================
|
|
|
+
|
|
|
+ @Test
|
|
|
+ @DisplayName("权限流:暂停他人家庭的任务模板 → 拒绝且不改库")
|
|
|
+ void pauseSchedule_crossFamily_rejected() throws Exception {
|
|
|
+ Harness h = harness();
|
|
|
+ Task otherFamilyTask = new Task();
|
|
|
+ otherFamilyTask.setId(9999L);
|
|
|
+ otherFamilyTask.setFamilyId(999L);
|
|
|
+ otherFamilyTask.setTitle("他家任务");
|
|
|
+ when(h.taskMapper.selectById(9999L)).thenReturn(otherFamilyTask);
|
|
|
+ // 当前用户 familyId=1(harness 固定),操作 familyId=999 的任务 → 越权
|
|
|
+
|
|
|
+ Method pause = findMethod(TaskService.class, "pauseSchedule", Long.class, Long.class);
|
|
|
+ assertNotNull(pause,
|
|
|
+ "[task.frequency.config] TaskService 缺 pauseSchedule:无法验证暂停的家庭作用域(SC-P-12 暂停/恢复)。");
|
|
|
+ IllegalArgumentException e = assertThrows(IllegalArgumentException.class,
|
|
|
+ () -> pause.invoke(h.service, 9999L, 1L));
|
|
|
+ assertTrue(e.getMessage().contains("不属于本家庭"),
|
|
|
+ "[task.frequency.config] 暂停他人家庭任务应拒绝并提示不属于本家庭,实际: " + e.getMessage());
|
|
|
+ verify(h.taskMapper, never()).updateById(any(Task.class));
|
|
|
+ }
|
|
|
+
|
|
|
+ // ==================== 辅助构造 ====================
|
|
|
+
|
|
|
+ private Task pausedTemplate() throws Exception {
|
|
|
+ Task t = new Task();
|
|
|
+ t.setId(9001L);
|
|
|
+ t.setFamilyId(1L);
|
|
|
+ t.setFamilyMemberId(1L);
|
|
|
+ t.setMemberId(100L);
|
|
|
+ t.setTitle("暂停模板");
|
|
|
+ t.setFrequency("daily");
|
|
|
+ t.setRepeatType("none");
|
|
|
+ t.setIsTemplate(1);
|
|
|
+ t.setNeedReview(0);
|
|
|
+ t.setStatus("pending");
|
|
|
+ t.setCreatedAt(new Date(date(2026, 10, 13).getTime()));
|
|
|
+ t.setDeadline(date(2026, 11, 31));
|
|
|
+ setFieldOrFail(t, "scheduleStatus", "paused",
|
|
|
+ "[task.frequency.config] Task 缺 scheduleStatus 字段:无法构造暂停中的模板。");
|
|
|
+ return t;
|
|
|
+ }
|
|
|
+
|
|
|
+ private CreateTaskDTO customCycleDto(Integer interval) throws Exception {
|
|
|
+ CreateTaskDTO dto = new CreateTaskDTO();
|
|
|
+ dto.setTitle("自定义周期任务");
|
|
|
+ dto.setPoints(5);
|
|
|
+ dto.setMemberId(100L);
|
|
|
+ dto.setExecutorType("child");
|
|
|
+ dto.setNeedReview(0);
|
|
|
+ dto.setFrequency("custom");
|
|
|
+ setFieldOrFail(dto, "cycleInterval", interval,
|
|
|
+ "[task.frequency.config] CreateTaskDTO 缺 cycleInterval 字段:无法声明自定义周期。");
|
|
|
+ setFieldOrFail(dto, "cycleUnit", "day",
|
|
|
+ "[task.frequency.config] CreateTaskDTO 缺 cycleUnit 字段:无法声明自定义周期单位。");
|
|
|
+ return dto;
|
|
|
+ }
|
|
|
+
|
|
|
+ /** TaskService 装配(Mockito,无 Spring/DB,风格同 TaskFrequencyScP12ContractTest)。 */
|
|
|
+ private static final class Harness {
|
|
|
+ final TaskService service = new TaskService();
|
|
|
+ final UserService userService = mock(UserService.class);
|
|
|
+ final FamilyMemberMapper familyMemberMapper = mock(FamilyMemberMapper.class);
|
|
|
+ final TaskMapper taskMapper = mock(TaskMapper.class);
|
|
|
+ final List<Task> inserted = new ArrayList<>();
|
|
|
+ long idSeq = 8000L;
|
|
|
+ }
|
|
|
+
|
|
|
+ private Harness harness() throws Exception {
|
|
|
+ Harness h = new Harness();
|
|
|
+ inject(h.service, "userService", h.userService);
|
|
|
+ inject(h.service, "familyMemberMapper", h.familyMemberMapper);
|
|
|
+ inject(h.service, "taskMapper", h.taskMapper);
|
|
|
+ inject(h.service, "miniGameMapper", mock(MiniGameMapper.class));
|
|
|
+ inject(h.service, "surveyTemplateMapper", mock(SurveyTemplateMapper.class));
|
|
|
+
|
|
|
+ User user = new User();
|
|
|
+ user.setId(1L);
|
|
|
+ user.setFamilyId(1L);
|
|
|
+ when(h.userService.getUserInfo(any())).thenReturn(user);
|
|
|
+ when(h.taskMapper.selectCount(any(Wrapper.class))).thenReturn(1L);
|
|
|
+ when(h.taskMapper.insert(any(Task.class))).thenAnswer(inv -> {
|
|
|
+ Task t = inv.getArgument(0);
|
|
|
+ if (t.getId() == null) {
|
|
|
+ t.setId(++h.idSeq);
|
|
|
+ }
|
|
|
+ t.setCreatedAt(new Date());
|
|
|
+ h.inserted.add(t);
|
|
|
+ return 1;
|
|
|
+ });
|
|
|
+ when(h.taskMapper.selectList(any(Wrapper.class))).thenReturn(new ArrayList<>());
|
|
|
+ return h;
|
|
|
+ }
|
|
|
+
|
|
|
+ // ==================== 反射辅助 ====================
|
|
|
+
|
|
|
+ private Class<?> loadClass(String name) {
|
|
|
+ try {
|
|
|
+ return Class.forName(name, false, Thread.currentThread().getContextClassLoader());
|
|
|
+ } catch (ClassNotFoundException e) {
|
|
|
+ return null;
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ private Class<?> requireClass(String name, String msg) {
|
|
|
+ Class<?> clazz = loadClass(name);
|
|
|
+ if (clazz == null) {
|
|
|
+ fail(msg);
|
|
|
+ }
|
|
|
+ return clazz;
|
|
|
+ }
|
|
|
+
|
|
|
+ /** 宿主实例(不存在即 fail,明确契约缺口)。 */
|
|
|
+ private Object contractHost(String name) throws Exception {
|
|
|
+ Class<?> clazz = loadClass(name);
|
|
|
+ if (clazz == null) {
|
|
|
+ fail("[task.frequency.config] 宿主 " + name + " 未实现:SC-P-12 契约行为缺失,测试红。");
|
|
|
+ }
|
|
|
+ return clazz.getDeclaredConstructor().newInstance();
|
|
|
+ }
|
|
|
+
|
|
|
+ private void assertHasField(Class<?> clazz, String field, String msg) throws Exception {
|
|
|
+ try {
|
|
|
+ clazz.getDeclaredField(field);
|
|
|
+ } catch (NoSuchFieldException e) {
|
|
|
+ fail(msg);
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ private void assertHasMethod(Class<?> clazz, String name, String msg, Class<?>... paramTypes) {
|
|
|
+ if (findMethod(clazz, name, paramTypes) == null) {
|
|
|
+ fail(msg);
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ private Method findMethod(Class<?> clazz, String name, Class<?>... paramTypes) {
|
|
|
+ try {
|
|
|
+ return clazz.getDeclaredMethod(name, paramTypes);
|
|
|
+ } catch (NoSuchMethodException e) {
|
|
|
+ return null;
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ private void inject(Object target, String field, Object value) throws Exception {
|
|
|
+ Field f = target.getClass().getDeclaredField(field);
|
|
|
+ f.setAccessible(true);
|
|
|
+ f.set(target, value);
|
|
|
+ }
|
|
|
+
|
|
|
+ private void setFieldOrFail(Object target, String field, Object value, String msg) throws Exception {
|
|
|
+ try {
|
|
|
+ Field f = target.getClass().getDeclaredField(field);
|
|
|
+ f.setAccessible(true);
|
|
|
+ f.set(target, value);
|
|
|
+ } catch (NoSuchFieldException e) {
|
|
|
+ fail(msg);
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ private Object reflectField(Object target, String field) throws Exception {
|
|
|
+ try {
|
|
|
+ Field f = target.getClass().getDeclaredField(field);
|
|
|
+ f.setAccessible(true);
|
|
|
+ return f.get(target);
|
|
|
+ } catch (NoSuchFieldException e) {
|
|
|
+ fail("[task.frequency.config] " + target.getClass().getSimpleName()
|
|
|
+ + " 缺 " + field + " 字段:契约字段未落地。");
|
|
|
+ return null;
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ private Date callDate(Object host, String method, Object... args) throws Exception {
|
|
|
+ Class<?>[] types = new Class<?>[args.length];
|
|
|
+ for (int i = 0; i < args.length; i++) {
|
|
|
+ types[i] = primitiveType(args[i].getClass());
|
|
|
+ }
|
|
|
+ Method m = host.getClass().getMethod(method, types);
|
|
|
+ return (Date) m.invoke(host, args);
|
|
|
+ }
|
|
|
+
|
|
|
+ private Class<?> primitiveType(Class<?> c) {
|
|
|
+ if (c == Integer.class) return int.class;
|
|
|
+ if (c == Long.class) return long.class;
|
|
|
+ if (c == Boolean.class) return boolean.class;
|
|
|
+ if (c == Double.class) return double.class;
|
|
|
+ if (c == Float.class) return float.class;
|
|
|
+ if (c == Short.class) return short.class;
|
|
|
+ if (c == Byte.class) return byte.class;
|
|
|
+ return c;
|
|
|
+ }
|
|
|
+
|
|
|
+ /** local 零点日期(javadoc 契约为本地时区 00:00:00)。 */
|
|
|
+ private Date date(int year, int month0, int day) {
|
|
|
+ Calendar c = Calendar.getInstance();
|
|
|
+ c.clear();
|
|
|
+ c.set(year, month0, day);
|
|
|
+ return c.getTime();
|
|
|
+ }
|
|
|
+}
|