mirror of
https://github.com/kuaifan/dootask.git
synced 2026-10-30 06:20:44 +00:00
test(task): 补充周期任务 refreshLoop 与 LoopTask 回归测试
- refreshLoop:字符串/整数自定义天数、无开始时间、开始时间早于今天、 各预设周期,以及 0/负数/非数字/小数/空值必须被拒绝 - LoopTask:到点只生成 1 个副本并关闭原任务周期;重复执行不再增加; 刷新周期失败时整体回滚不留副本(含子任务)且不会滚雪球; 超出窗口、已归档项目、子任务不被处理
This commit is contained in:
parent
5a10f59df8
commit
e45608df36
206
tests/Feature/LoopTaskTest.php
Normal file
206
tests/Feature/LoopTaskTest.php
Normal 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()));
|
||||
}
|
||||
}
|
||||
122
tests/Unit/ProjectTaskRefreshLoopTest.php
Normal file
122
tests/Unit/ProjectTaskRefreshLoopTest.php
Normal 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);
|
||||
}
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user