diff --git a/tests/Feature/LoopTaskTest.php b/tests/Feature/LoopTaskTest.php new file mode 100644 index 000000000..9bc48c96f --- /dev/null +++ b/tests/Feature/LoopTaskTest.php @@ -0,0 +1,206 @@ +user = User::createInstance([ + 'email' => 'looptask_' . uniqid() . '@example.com', + 'userimg' => '', + 'nickname' => 'LoopTaskUser', + 'profession' => '', + 'password' => md5('123456'), + ]); + $this->user->save(); + $this->project = Project::createInstance([ + 'name' => 'looptask-' . uniqid(), + 'desc' => '', + 'userid' => $this->user->userid, + 'personal' => 0, + ]); + $this->project->save(); + $this->column = ProjectColumn::createInstance([ + 'project_id' => $this->project->id, + 'name' => 'Default', + ]); + $this->column->save(); + } + + /** + * 创建一个顶级周期任务 + * @param mixed $loop 重复周期(与库里一致,数字周期是字符串) + * @param Carbon $loopAt 下个周期时间 + */ + private function makeLoopTask($loop, Carbon $loopAt, array $overrides = []): ProjectTask + { + $task = ProjectTask::createInstance(array_merge([ + 'project_id' => $this->project->id, + 'column_id' => $this->column->id, + 'parent_id' => 0, + 'name' => 'loop-' . uniqid(), + 'userid' => $this->user->userid, + 'start_at' => $loopAt->clone(), + 'end_at' => $loopAt->clone()->addHour(), + 'loop' => (string)$loop, + 'loop_at' => $loopAt->clone(), + ], $overrides)); + $task->save(); + return $task; + } + + private function runLoopTask(): void + { + (new LoopTask())->start(); + } + + private function projectTasks() + { + return ProjectTask::whereProjectId($this->project->id)->whereParentId(0)->orderBy('id')->get(); + } + + private function failureLogs(int $taskId) + { + return ProjectLog::whereTaskId($taskId)->where('detail', 'like', '生成重复任务失败%')->get(); + } + + public function test_custom_days_loop_creates_exactly_one_copy_and_closes_source(): void + { + $due = Carbon::now()->subMinute(); + $source = $this->makeLoopTask('90', $due); + + $this->runLoopTask(); + + $tasks = $this->projectTasks(); + $this->assertCount(2, $tasks, '到点只应生成 1 个新任务'); + $source = $source->fresh(); + $this->assertSame('', (string)$source->loop, '原任务周期应被关闭'); + $this->assertNull($source->loop_at); + + $copy = $tasks->last(); + $this->assertNotSame($source->id, $copy->id); + $this->assertSame('90', (string)$copy->loop, '新任务继承周期'); + $this->assertTrue(Carbon::parse($copy->loop_at)->gt(Carbon::now()->addDays(88)), '新任务的下个周期应在约 90 天后'); + $this->assertSame($due->toDateTimeString(), Carbon::parse($copy->start_at)->toDateTimeString(), '新任务开始时间等于原任务的 loop_at'); + $this->assertSame(3600, (int)Carbon::parse($copy->start_at)->diffInSeconds(Carbon::parse($copy->end_at)), '新任务时长与原任务一致'); + } + + public function test_second_run_does_not_create_more_copies(): void + { + $this->makeLoopTask('90', Carbon::now()->subMinute()); + + $this->runLoopTask(); + $this->runLoopTask(); + $this->runLoopTask(); + + $this->assertCount(2, $this->projectTasks()); + } + + /** + * 历史脏数据(如 loop='0'):刷新周期失败时必须整体回滚,不能留下 loop_at 仍在窗口内的副本 + */ + public function test_refresh_failure_rolls_back_without_leaving_copies(): void + { + $source = $this->makeLoopTask('0', Carbon::now()->subMinute()); + + $this->runLoopTask(); + + $this->assertCount(1, $this->projectTasks(), '失败不应留下任何副本'); + $this->assertCount(1, $this->failureLogs($source->id), '应记录一条失败日志'); + $this->assertNotNull($source->fresh()->loop_at, '失败时原任务周期保持不变,窗口内可重试'); + } + + public function test_repeated_failures_never_snowball(): void + { + $source = $this->makeLoopTask('0', Carbon::now()->subMinute()); + + for ($i = 0; $i < 5; $i++) { + $this->runLoopTask(); + } + + $this->assertCount(1, $this->projectTasks()); + $this->assertCount(5, $this->failureLogs($source->id), '每轮只对原任务失败一次'); + } + + public function test_failure_rolls_back_subtasks_and_members_too(): void + { + $source = $this->makeLoopTask('0', Carbon::now()->subMinute()); + $this->makeLoopTask('day', Carbon::now()->subMinute(), [ + 'parent_id' => $source->id, + 'loop' => '', + 'loop_at' => null, + ]); + + $before = ProjectTask::whereProjectId($this->project->id)->count(); + $this->runLoopTask(); + + $this->assertSame($before, ProjectTask::whereProjectId($this->project->id)->count(), '副本及其子任务都应回滚'); + } + + public function test_due_time_outside_window_is_ignored(): void + { + $this->makeLoopTask('day', Carbon::now()->subMinutes(30)); + $this->makeLoopTask('day', Carbon::now()->addMinutes(30)); + + $this->runLoopTask(); + + $this->assertCount(2, $this->projectTasks()); + } + + public function test_archived_project_is_skipped(): void + { + $this->project->archived_at = Carbon::now(); + $this->project->save(); + $this->makeLoopTask('day', Carbon::now()->subMinute()); + + $this->runLoopTask(); + + $this->assertCount(1, $this->projectTasks()); + } + + public function test_subtask_is_not_processed(): void + { + $parent = $this->makeLoopTask('never', Carbon::now()->subDay(), ['loop' => '', 'loop_at' => null]); + $sub = $this->makeLoopTask('day', Carbon::now()->subMinute(), ['parent_id' => $parent->id]); + + $this->runLoopTask(); + + $this->assertSame(0, ProjectTask::whereParentId($parent->id)->where('id', '!=', $sub->id)->count()); + $this->assertCount(1, $this->projectTasks()); + } + + public function test_preset_loop_creates_one_copy_with_next_cycle(): void + { + $this->makeLoopTask('day', Carbon::now()->subMinute()); + + $this->runLoopTask(); + + $tasks = $this->projectTasks(); + $this->assertCount(2, $tasks); + $this->assertTrue(Carbon::parse($tasks->last()->loop_at)->gt(Carbon::now())); + } +} diff --git a/tests/Unit/ProjectTaskRefreshLoopTest.php b/tests/Unit/ProjectTaskRefreshLoopTest.php new file mode 100644 index 000000000..758a7ce24 --- /dev/null +++ b/tests/Unit/ProjectTaskRefreshLoopTest.php @@ -0,0 +1,122 @@ + 下个周期时间 + * + * 回归:Carbon 3 的 addDays() 不接受字符串,库里 loop 是 varchar("90"), + * 曾导致自定义天数周期在 LoopTask 里抛 TypeError 并引发无限复制。 + */ +class ProjectTaskRefreshLoopTest extends TestCase +{ + private function task($loop, ?Carbon $startAt = null): ProjectTask + { + $task = new ProjectTask(); + $task->loop = $loop; + $task->start_at = $startAt; + return $task; + } + + private function todayAt(string $time): Carbon + { + return Carbon::parse(date("Y-m-d {$time}")); + } + + public function test_custom_days_accept_string_from_database(): void + { + $start = $this->todayAt('09:00:00'); + $task = $this->task('90', $start); + + $this->assertTrue($task->refreshLoop()); + $this->assertSame($start->clone()->addDays(90)->toDateTimeString(), Carbon::parse($task->loop_at)->toDateTimeString()); + } + + public function test_custom_days_accept_int_from_request(): void + { + $start = $this->todayAt('09:00:00'); + $task = $this->task(90, $start); + + $this->assertTrue($task->refreshLoop()); + $this->assertSame($start->clone()->addDays(90)->toDateTimeString(), Carbon::parse($task->loop_at)->toDateTimeString()); + } + + public function test_custom_days_without_start_time_use_today_as_base(): void + { + $task = $this->task('3'); + + $this->assertTrue($task->refreshLoop()); + $this->assertSame(Carbon::today()->addDays(3)->toDateTimeString(), Carbon::parse($task->loop_at)->toDateTimeString()); + } + + public function test_past_start_time_is_rebased_to_today_keeping_time_of_day(): void + { + $task = $this->task('day', $this->todayAt('09:30:00')->subDays(5)); + + $this->assertTrue($task->refreshLoop()); + $this->assertSame($this->todayAt('09:30:00')->addDay()->toDateTimeString(), Carbon::parse($task->loop_at)->toDateTimeString()); + } + + /** + * 0 天会让新任务的 loop_at 等于自己的开始时间,立刻再次命中周期窗口,必须拒绝 + */ + #[DataProvider('invalidLoops')] + public function test_invalid_loop_is_rejected_and_clears_loop_at(mixed $loop): void + { + $task = $this->task($loop, $this->todayAt('09:00:00')); + $task->loop_at = $this->todayAt('09:00:00'); + + $this->assertFalse($task->refreshLoop()); + $this->assertNull($task->loop_at); + } + + public static function invalidLoops(): array + { + return [ + 'zero string' => ['0'], + 'zero int' => [0], + 'zero padded' => ['00'], + 'negative' => ['-1'], + 'letters' => ['abc'], + 'decimal' => ['1.5'], + 'empty' => [''], + 'null' => [null], + ]; + } + + #[DataProvider('presetLoops')] + public function test_preset_loops(string $loop, callable $expected): void + { + $start = $this->todayAt('09:00:00'); + $task = $this->task($loop, $start); + + $this->assertTrue($task->refreshLoop()); + $this->assertSame($expected($start->clone())->toDateTimeString(), Carbon::parse($task->loop_at)->toDateTimeString()); + } + + public static function presetLoops(): array + { + return [ + 'day' => ['day', fn(Carbon $c) => $c->addDay()], + 'weekdays' => ['weekdays', fn(Carbon $c) => $c->addWeekday()], + 'week' => ['week', fn(Carbon $c) => $c->addWeek()], + 'twoweeks' => ['twoweeks', fn(Carbon $c) => $c->addWeeks(2)], + 'month' => ['month', fn(Carbon $c) => $c->addMonth()], + 'year' => ['year', fn(Carbon $c) => $c->addYear()], + ]; + } + + public function test_never_clears_loop_at_and_succeeds(): void + { + $task = $this->task('never', $this->todayAt('09:00:00')); + $task->loop_at = $this->todayAt('09:00:00'); + + $this->assertTrue($task->refreshLoop()); + $this->assertNull($task->loop_at); + } +}