test(task): 补充周期任务 refreshLoop 与 LoopTask 回归测试

- refreshLoop:字符串/整数自定义天数、无开始时间、开始时间早于今天、
  各预设周期,以及 0/负数/非数字/小数/空值必须被拒绝
- LoopTask:到点只生成 1 个副本并关闭原任务周期;重复执行不再增加;
  刷新周期失败时整体回滚不留副本(含子任务)且不会滚雪球;
  超出窗口、已归档项目、子任务不被处理
This commit is contained in:
kuaifan 2026-09-30 14:53:52 +00:00
parent 5a10f59df8
commit e45608df36
2 changed files with 328 additions and 0 deletions

View File

@ -0,0 +1,206 @@
<?php
namespace Tests\Feature;
use App\Models\Project;
use App\Models\ProjectColumn;
use App\Models\ProjectLog;
use App\Models\ProjectTask;
use App\Models\User;
use App\Tasks\LoopTask;
use Carbon\Carbon;
use Illuminate\Foundation\Testing\DatabaseTransactions;
use Tests\TestCase;
/**
* LoopTask:周期任务到点生成新任务
*
* 回归:LoopTask 曾先提交副本、再刷新周期;刷新失败(Carbon 3 的 addDays 收到字符串)时
* 副本与原任务的 loop_at 都留在 10 分钟窗口内,每分钟翻倍并被同一轮 chunkById 继续扫到。
*/
class LoopTaskTest extends TestCase
{
use DatabaseTransactions;
private Project $project;
private ProjectColumn $column;
private User $user;
protected function setUp(): void
{
parent::setUp();
$this->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()));
}
}

View File

@ -0,0 +1,122 @@
<?php
namespace Tests\Unit;
use App\Models\ProjectTask;
use Carbon\Carbon;
use PHPUnit\Framework\Attributes\DataProvider;
use Tests\TestCase;
/**
* refreshLoop:重复周期 -> 下个周期时间
*
* 回归: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);
}
}