mirror of
https://github.com/kuaifan/dootask.git
synced 2026-05-24 17:34:02 +00:00
424 lines
17 KiB
PHP
424 lines
17 KiB
PHP
<?php
|
|
|
|
namespace Tests\Feature;
|
|
|
|
use App\Models\Project;
|
|
use App\Models\ProjectTask;
|
|
use App\Models\ProjectTaskTemplate;
|
|
use App\Models\ProjectUser;
|
|
use App\Models\User;
|
|
use App\Module\Base;
|
|
use Illuminate\Foundation\Testing\DatabaseTransactions;
|
|
use Illuminate\Support\Facades\DB;
|
|
use Tests\TestCase;
|
|
|
|
class CrossProjectTaskTemplateTest extends TestCase
|
|
{
|
|
use DatabaseTransactions;
|
|
|
|
/**
|
|
* 创建测试用户。
|
|
*/
|
|
private function makeUser(string $email): User
|
|
{
|
|
$user = User::createInstance([
|
|
'email' => $email,
|
|
'userimg' => '',
|
|
'nickname' => 'TestUser_' . substr(md5($email), 0, 6),
|
|
'profession' => '',
|
|
'password' => md5('123456'),
|
|
]);
|
|
$user->save();
|
|
return $user;
|
|
}
|
|
|
|
/**
|
|
* 创建项目,自动把 owner 加为主负责人,把 members 加为普通成员。
|
|
*/
|
|
private function makeProject(int $ownerUserid, array $memberUserids = []): Project
|
|
{
|
|
$project = Project::createInstance([
|
|
'name' => 'p-' . uniqid(),
|
|
'desc' => '',
|
|
'userid' => $ownerUserid,
|
|
'personal' => 0,
|
|
]);
|
|
$project->save();
|
|
ProjectUser::updateInsert([
|
|
'project_id' => $project->id,
|
|
'userid' => $ownerUserid,
|
|
], ['owner' => 1]);
|
|
foreach ($memberUserids as $uid) {
|
|
if ($uid === $ownerUserid) continue;
|
|
ProjectUser::updateInsert([
|
|
'project_id' => $project->id,
|
|
'userid' => $uid,
|
|
], ['owner' => 0]);
|
|
}
|
|
return $project;
|
|
}
|
|
|
|
/**
|
|
* 创建一个任务模板。
|
|
*/
|
|
private function makeTemplate(Project $project, int $userid, array $overrides = []): ProjectTaskTemplate
|
|
{
|
|
$tpl = ProjectTaskTemplate::createInstance(array_merge([
|
|
'project_id' => $project->id,
|
|
'name' => 'tpl-' . uniqid(),
|
|
'title' => 'title-' . uniqid(),
|
|
'content' => 'content',
|
|
'sort' => 0,
|
|
'is_default' => 0,
|
|
'userid' => $userid,
|
|
'use_count' => 0,
|
|
], $overrides));
|
|
$tpl->save();
|
|
return $tpl;
|
|
}
|
|
|
|
/**
|
|
* 复刻 task__template_search 业务逻辑。
|
|
*/
|
|
private function callTemplateSearch(int $userid, string $keyword = '', int $page = 1, int $pageSize = 20): array
|
|
{
|
|
$projectIds = ProjectUser::where('userid', $userid)->pluck('project_id');
|
|
$q = ProjectTaskTemplate::with(['project:id,name'])
|
|
->whereIn('project_id', $projectIds);
|
|
if ($keyword !== '') {
|
|
$q->where(function ($q2) use ($keyword) {
|
|
$like = '%' . $keyword . '%';
|
|
$q2->where('name', 'like', $like)
|
|
->orWhere('title', 'like', $like)
|
|
->orWhere('content', 'like', $like);
|
|
});
|
|
}
|
|
$total = $q->count();
|
|
$items = $q->orderByDesc('use_count')
|
|
->orderByDesc('last_used_at')
|
|
->orderByDesc('created_at')
|
|
->forPage($page, $pageSize)
|
|
->get()
|
|
->map(fn($tpl) => [
|
|
'id' => $tpl->id,
|
|
'project_id' => $tpl->project_id,
|
|
'project_name' => $tpl->project->name ?? '',
|
|
'name' => $tpl->name,
|
|
'title' => $tpl->title,
|
|
'content' => $tpl->content,
|
|
'use_count' => $tpl->use_count,
|
|
])->toArray();
|
|
return ['total' => $total, 'items' => $items, 'page' => $page, 'page_size' => $pageSize];
|
|
}
|
|
|
|
/**
|
|
* 复刻共享模板关闭后的搜索范围:目标项目关闭共享模板时,仅返回目标项目自己的模板。
|
|
*/
|
|
private function callTemplateSearchForProject(int $userid, int $currentProjectId, string $keyword = '', int $page = 1, int $pageSize = 20): array
|
|
{
|
|
$projectIds = ProjectUser::where('userid', $userid)->pluck('project_id');
|
|
$currentProject = Project::find($currentProjectId);
|
|
if ($currentProject && ($currentProject->task_template_share ?: 'open') === 'close') {
|
|
$projectIds = collect($projectIds)->filter(fn($id) => intval($id) === $currentProjectId)->values();
|
|
}
|
|
$q = ProjectTaskTemplate::with(['project:id,name'])
|
|
->whereIn('project_id', $projectIds);
|
|
if ($keyword !== '') {
|
|
$q->where(function ($q2) use ($keyword) {
|
|
$like = '%' . $keyword . '%';
|
|
$q2->where('name', 'like', $like)
|
|
->orWhere('title', 'like', $like)
|
|
->orWhere('content', 'like', $like);
|
|
});
|
|
}
|
|
$total = $q->count();
|
|
$items = $q->orderByDesc('use_count')
|
|
->orderByDesc('last_used_at')
|
|
->orderByDesc('created_at')
|
|
->forPage($page, $pageSize)
|
|
->get()
|
|
->map(fn($tpl) => [
|
|
'id' => $tpl->id,
|
|
'project_id' => $tpl->project_id,
|
|
'name' => $tpl->name,
|
|
])->toArray();
|
|
return ['total' => $total, 'items' => $items, 'page' => $page, 'page_size' => $pageSize];
|
|
}
|
|
|
|
/**
|
|
* 模拟 task__add 的"使用模板"副作用:检查 template_id 可见性,原子递增 use_count + 更新 last_used_at。
|
|
* 不实际创建任务,只验证副作用。
|
|
*/
|
|
private function simulateUseTemplate(int $userid, int $templateId, ?int $targetProjectId = null): void
|
|
{
|
|
if ($templateId <= 0) return;
|
|
$tpl = ProjectTaskTemplate::find($templateId);
|
|
if (!$tpl) return;
|
|
$isMember = ProjectUser::where('project_id', $tpl->project_id)
|
|
->where('userid', $userid)->exists();
|
|
if (!$isMember) return;
|
|
if ($targetProjectId) {
|
|
$targetProject = Project::find($targetProjectId);
|
|
$shareEnabled = !$targetProject || ($targetProject->task_template_share ?: 'open') === 'open';
|
|
if ($tpl->project_id != $targetProjectId && !$shareEnabled) {
|
|
return;
|
|
}
|
|
}
|
|
$tpl->incrementUsage();
|
|
}
|
|
|
|
/**
|
|
* 调用 task__template_visible 端点(绕过 HTTP 层,直接复刻 controller 业务逻辑)。
|
|
*/
|
|
private function callTemplateVisible(int $userid, int $currentProjectId): array
|
|
{
|
|
$projectIds = ProjectUser::where('userid', $userid)->pluck('project_id');
|
|
$currentProject = Project::find($currentProjectId);
|
|
if ($currentProject && ($currentProject->task_template_share ?: 'open') === 'close') {
|
|
$projectIds = collect($projectIds)->filter(fn($id) => intval($id) === $currentProjectId)->values();
|
|
}
|
|
return ProjectTaskTemplate::with(['project:id,name'])
|
|
->whereIn('project_id', $projectIds)
|
|
->orderByRaw('project_id = ? DESC', [$currentProjectId])
|
|
->orderBy('sort')
|
|
->orderBy('id')
|
|
->get()
|
|
->map(fn($tpl) => [
|
|
'id' => $tpl->id,
|
|
'project_id' => $tpl->project_id,
|
|
'project_name' => $tpl->project->name ?? '',
|
|
'name' => $tpl->name,
|
|
'title' => $tpl->title,
|
|
'content' => $tpl->content,
|
|
'sort' => $tpl->sort,
|
|
'is_default' => $tpl->is_default,
|
|
'userid' => $tpl->userid,
|
|
'use_count' => $tpl->use_count,
|
|
'last_used_at' => $tpl->last_used_at,
|
|
])->toArray();
|
|
}
|
|
|
|
public function test_search_paginates_visible_templates_by_use_count_desc()
|
|
{
|
|
$alice = $this->makeUser('alice-' . uniqid() . '@test.com');
|
|
$projectA = $this->makeProject($alice->userid);
|
|
foreach (range(1, 25) as $i) {
|
|
$this->makeTemplate($projectA, $alice->userid, [
|
|
'name' => 'tpl-' . str_pad($i, 2, '0', STR_PAD_LEFT),
|
|
'use_count' => $i,
|
|
]);
|
|
}
|
|
|
|
$page1 = $this->callTemplateSearch($alice->userid, '', 1, 20);
|
|
$page2 = $this->callTemplateSearch($alice->userid, '', 2, 20);
|
|
|
|
$this->assertSame(25, $page1['total']);
|
|
$this->assertCount(20, $page1['items']);
|
|
$this->assertCount(5, $page2['items']);
|
|
$this->assertSame('tpl-25', $page1['items'][0]['name']);
|
|
$this->assertSame('tpl-06', $page1['items'][19]['name']);
|
|
$this->assertSame('tpl-05', $page2['items'][0]['name']);
|
|
}
|
|
|
|
public function test_search_filters_by_keyword_in_name_title_content()
|
|
{
|
|
$alice = $this->makeUser('alice-' . uniqid() . '@test.com');
|
|
$projectA = $this->makeProject($alice->userid);
|
|
$this->makeTemplate($projectA, $alice->userid, ['name' => '需求评审', 'title' => 't1', 'content' => 'c1']);
|
|
$this->makeTemplate($projectA, $alice->userid, ['name' => 'tpl2', 'title' => '周报', 'content' => 'c2']);
|
|
$this->makeTemplate($projectA, $alice->userid, ['name' => 'tpl3', 'title' => 't3', 'content' => '故障复盘']);
|
|
$this->makeTemplate($projectA, $alice->userid, ['name' => 'noise', 'title' => 'noise', 'content' => 'noise']);
|
|
|
|
$r1 = $this->callTemplateSearch($alice->userid, '需求');
|
|
$r2 = $this->callTemplateSearch($alice->userid, '周报');
|
|
$r3 = $this->callTemplateSearch($alice->userid, '故障');
|
|
|
|
$this->assertSame(1, $r1['total']);
|
|
$this->assertSame('需求评审', $r1['items'][0]['name']);
|
|
$this->assertSame(1, $r2['total']);
|
|
$this->assertSame('tpl2', $r2['items'][0]['name']);
|
|
$this->assertSame(1, $r3['total']);
|
|
$this->assertSame('tpl3', $r3['items'][0]['name']);
|
|
}
|
|
|
|
public function test_search_excludes_non_member_project_templates()
|
|
{
|
|
$alice = $this->makeUser('alice-' . uniqid() . '@test.com');
|
|
$bob = $this->makeUser('bob-' . uniqid() . '@test.com');
|
|
$projectA = $this->makeProject($alice->userid);
|
|
$projectC = $this->makeProject($bob->userid);
|
|
$this->makeTemplate($projectA, $alice->userid, ['name' => 'visible']);
|
|
$this->makeTemplate($projectC, $bob->userid, ['name' => 'hidden']);
|
|
|
|
$r = $this->callTemplateSearch($alice->userid, '');
|
|
|
|
$names = array_column($r['items'], 'name');
|
|
$this->assertContains('visible', $names);
|
|
$this->assertNotContains('hidden', $names);
|
|
}
|
|
|
|
public function test_search_sort_falls_back_when_use_count_equal()
|
|
{
|
|
$alice = $this->makeUser('alice-' . uniqid() . '@test.com');
|
|
$projectA = $this->makeProject($alice->userid);
|
|
$t1 = $this->makeTemplate($projectA, $alice->userid, ['name' => 'older', 'use_count' => 5]);
|
|
sleep(1);
|
|
$t2 = $this->makeTemplate($projectA, $alice->userid, ['name' => 'newer', 'use_count' => 5]);
|
|
$t2->last_used_at = now();
|
|
$t2->save();
|
|
|
|
$r = $this->callTemplateSearch($alice->userid, '');
|
|
|
|
$this->assertSame('newer', $r['items'][0]['name']);
|
|
}
|
|
|
|
public function test_search_endpoint_returns_expected_shape()
|
|
{
|
|
$alice = $this->makeUser('alice-' . uniqid() . '@test.com');
|
|
$projectA = $this->makeProject($alice->userid);
|
|
$this->makeTemplate($projectA, $alice->userid, ['name' => 'sanity', 'use_count' => 1]);
|
|
|
|
$projectIds = ProjectUser::where('userid', $alice->userid)->pluck('project_id');
|
|
$expected = ProjectTaskTemplate::whereIn('project_id', $projectIds)
|
|
->orderByDesc('use_count')->orderByDesc('last_used_at')->orderByDesc('created_at')
|
|
->forPage(1, 20)->get()->pluck('name')->toArray();
|
|
|
|
$actual = array_column($this->callTemplateSearch($alice->userid, '')['items'], 'name');
|
|
|
|
$this->assertSame($expected, $actual);
|
|
}
|
|
|
|
public function test_visible_returns_templates_from_all_user_projects()
|
|
{
|
|
$alice = $this->makeUser('alice-' . uniqid() . '@test.com');
|
|
$projectA = $this->makeProject($alice->userid);
|
|
$projectB = $this->makeProject($alice->userid);
|
|
$tplA = $this->makeTemplate($projectA, $alice->userid, ['name' => 'A1']);
|
|
$tplB = $this->makeTemplate($projectB, $alice->userid, ['name' => 'B1']);
|
|
|
|
$result = $this->callTemplateVisible($alice->userid, $projectB->id);
|
|
|
|
$names = array_column($result, 'name');
|
|
$this->assertContains('A1', $names);
|
|
$this->assertContains('B1', $names);
|
|
}
|
|
|
|
public function test_visible_excludes_templates_from_non_member_projects()
|
|
{
|
|
$alice = $this->makeUser('alice-' . uniqid() . '@test.com');
|
|
$bob = $this->makeUser('bob-' . uniqid() . '@test.com');
|
|
$projectA = $this->makeProject($alice->userid);
|
|
$projectC = $this->makeProject($bob->userid);
|
|
$tplA = $this->makeTemplate($projectA, $alice->userid, ['name' => 'A1']);
|
|
$tplC = $this->makeTemplate($projectC, $bob->userid, ['name' => 'C1']);
|
|
|
|
$result = $this->callTemplateVisible($alice->userid, $projectA->id);
|
|
|
|
$names = array_column($result, 'name');
|
|
$this->assertContains('A1', $names);
|
|
$this->assertNotContains('C1', $names);
|
|
}
|
|
|
|
public function test_visible_orders_current_project_first()
|
|
{
|
|
$alice = $this->makeUser('alice-' . uniqid() . '@test.com');
|
|
$projectA = $this->makeProject($alice->userid);
|
|
$projectB = $this->makeProject($alice->userid);
|
|
$this->makeTemplate($projectA, $alice->userid, ['name' => 'A1', 'sort' => 0]);
|
|
$this->makeTemplate($projectB, $alice->userid, ['name' => 'B1', 'sort' => 0]);
|
|
|
|
$result = $this->callTemplateVisible($alice->userid, $projectB->id);
|
|
|
|
// B 项目模板应该排在前面
|
|
$this->assertSame('B1', $result[0]['name']);
|
|
}
|
|
|
|
public function test_use_template_increments_use_count_and_updates_last_used()
|
|
{
|
|
$alice = $this->makeUser('alice-' . uniqid() . '@test.com');
|
|
$projectA = $this->makeProject($alice->userid);
|
|
$tpl = $this->makeTemplate($projectA, $alice->userid, ['use_count' => 3]);
|
|
|
|
$before = $tpl->fresh();
|
|
$this->assertSame(3, (int) $before->use_count);
|
|
$this->assertNull($before->last_used_at);
|
|
|
|
$this->simulateUseTemplate($alice->userid, $tpl->id);
|
|
|
|
$after = $tpl->fresh();
|
|
$this->assertSame(4, (int) $after->use_count);
|
|
$this->assertNotNull($after->last_used_at);
|
|
}
|
|
|
|
public function test_use_template_silently_ignores_non_member()
|
|
{
|
|
$alice = $this->makeUser('alice-' . uniqid() . '@test.com');
|
|
$bob = $this->makeUser('bob-' . uniqid() . '@test.com');
|
|
$projectA = $this->makeProject($alice->userid);
|
|
$tpl = $this->makeTemplate($projectA, $alice->userid, ['use_count' => 3]);
|
|
|
|
$this->simulateUseTemplate($bob->userid, $tpl->id);
|
|
|
|
$this->assertSame(3, (int) $tpl->fresh()->use_count);
|
|
}
|
|
|
|
public function test_use_template_handles_invalid_template_id()
|
|
{
|
|
$alice = $this->makeUser('alice-' . uniqid() . '@test.com');
|
|
$this->simulateUseTemplate($alice->userid, 0);
|
|
$this->simulateUseTemplate($alice->userid, 99999999);
|
|
$this->assertTrue(true);
|
|
}
|
|
|
|
public function test_visible_returns_only_current_project_templates_when_share_closed()
|
|
{
|
|
$alice = $this->makeUser('alice-' . uniqid() . '@test.com');
|
|
$projectA = $this->makeProject($alice->userid);
|
|
$projectB = $this->makeProject($alice->userid);
|
|
$projectB->task_template_share = 'close';
|
|
$projectB->save();
|
|
$this->makeTemplate($projectA, $alice->userid, ['name' => 'A1']);
|
|
$this->makeTemplate($projectB, $alice->userid, ['name' => 'B1']);
|
|
|
|
$result = $this->callTemplateVisible($alice->userid, $projectB->id);
|
|
|
|
$names = array_column($result, 'name');
|
|
$this->assertNotContains('A1', $names);
|
|
$this->assertContains('B1', $names);
|
|
}
|
|
|
|
public function test_search_returns_only_current_project_templates_when_share_closed()
|
|
{
|
|
$alice = $this->makeUser('alice-' . uniqid() . '@test.com');
|
|
$projectA = $this->makeProject($alice->userid);
|
|
$projectB = $this->makeProject($alice->userid);
|
|
$projectB->task_template_share = 'close';
|
|
$projectB->save();
|
|
$this->makeTemplate($projectA, $alice->userid, ['name' => 'shared']);
|
|
$this->makeTemplate($projectB, $alice->userid, ['name' => 'own']);
|
|
|
|
$result = $this->callTemplateSearchForProject($alice->userid, $projectB->id);
|
|
|
|
$names = array_column($result['items'], 'name');
|
|
$this->assertNotContains('shared', $names);
|
|
$this->assertContains('own', $names);
|
|
}
|
|
|
|
public function test_cross_project_template_usage_ignored_when_target_project_share_closed()
|
|
{
|
|
$alice = $this->makeUser('alice-' . uniqid() . '@test.com');
|
|
$projectA = $this->makeProject($alice->userid);
|
|
$projectB = $this->makeProject($alice->userid);
|
|
$projectB->task_template_share = 'close';
|
|
$projectB->save();
|
|
$tplA = $this->makeTemplate($projectA, $alice->userid, ['use_count' => 7]);
|
|
$tplB = $this->makeTemplate($projectB, $alice->userid, ['use_count' => 3]);
|
|
|
|
$this->simulateUseTemplate($alice->userid, $tplA->id, $projectB->id);
|
|
$this->simulateUseTemplate($alice->userid, $tplB->id, $projectB->id);
|
|
|
|
$this->assertSame(7, (int) $tplA->fresh()->use_count);
|
|
$this->assertSame(4, (int) $tplB->fresh()->use_count);
|
|
}
|
|
}
|