mirror of
https://github.com/kuaifan/dootask.git
synced 2026-09-14 16:08:35 +00:00
381 lines
17 KiB
PHP
381 lines
17 KiB
PHP
<?php
|
|
|
|
namespace Tests\Feature;
|
|
|
|
use App\Exceptions\ApiException;
|
|
use App\Http\Controllers\Api\ProjectTaskHandoffController;
|
|
use App\Models\Project;
|
|
use App\Models\ProjectFlow;
|
|
use App\Models\ProjectFlowItem;
|
|
use App\Models\ProjectTask;
|
|
use App\Models\ProjectTaskHandoff;
|
|
use App\Models\ProjectTaskUser;
|
|
use App\Models\ProjectUser;
|
|
use App\Models\User;
|
|
use App\Models\UserDepartment;
|
|
use App\Module\Base;
|
|
use App\Module\Interface\DooSo;
|
|
use App\Module\ProjectTaskHandoffService;
|
|
use App\Module\ProjectTaskHandoffRecord;
|
|
use App\Services\RequestContext;
|
|
use Illuminate\Database\Eloquent\Model;
|
|
use Illuminate\Events\Dispatcher;
|
|
use Illuminate\Foundation\Http\Middleware\ConvertEmptyStringsToNull;
|
|
use Illuminate\Foundation\Http\Middleware\TrimStrings;
|
|
use Illuminate\Foundation\Testing\DatabaseTransactions;
|
|
use Illuminate\Http\Request;
|
|
use Illuminate\Pipeline\Pipeline;
|
|
use Illuminate\Support\Facades\DB;
|
|
use Illuminate\Support\Facades\Request as RequestFacade;
|
|
use Mockery;
|
|
use Tests\TestCase;
|
|
|
|
class ProjectTaskHandoffFeatureTest extends TestCase
|
|
{
|
|
use DatabaseTransactions;
|
|
|
|
private $originalDispatcher;
|
|
private User $leader;
|
|
private User $inside;
|
|
private User $outside;
|
|
private User $receiver;
|
|
private Project $project;
|
|
private ProjectTask $task;
|
|
private UserDepartment $department;
|
|
|
|
protected function setUp(): void
|
|
{
|
|
parent::setUp();
|
|
config(['cache.default' => 'array']);
|
|
// Keep tests transactional and suppress unrelated Swoole/search/external hooks.
|
|
$this->originalDispatcher = Model::getEventDispatcher();
|
|
Model::setEventDispatcher(new Dispatcher(app()));
|
|
RequestContext::clean();
|
|
$this->leader = $this->user('leader');
|
|
$this->inside = $this->user('inside');
|
|
$this->outside = $this->user('outside');
|
|
$this->receiver = $this->user('receiver');
|
|
$this->login($this->leader);
|
|
$this->department = UserDepartment::createInstance(['name' => 'Handoff test', 'parent_id' => 0, 'owner_userid' => $this->leader->userid]);
|
|
$this->department->save();
|
|
foreach ([$this->inside, $this->receiver] as $user) {
|
|
$user->department = ',' . $this->department->id . ',';
|
|
$user->save();
|
|
}
|
|
$this->project = Project::createInstance(['name' => 'Handoff test', 'department_owner_view' => 'open']);
|
|
$this->project->save();
|
|
foreach ([$this->inside, $this->outside, $this->receiver] as $user) {
|
|
ProjectUser::createInstance(['project_id' => $this->project->id, 'userid' => $user->userid, 'owner' => $user === $this->outside ? 1 : 0])->save();
|
|
}
|
|
$this->task = ProjectTask::createInstance([
|
|
'project_id' => $this->project->id, 'parent_id' => 0, 'name' => 'Handoff test',
|
|
'userid' => $this->outside->userid, 'visibility' => 1, 'dialog_id' => 0,
|
|
]);
|
|
$this->task->save();
|
|
foreach ([$this->inside, $this->outside] as $user) {
|
|
ProjectTaskUser::createInstance([
|
|
'project_id' => $this->project->id, 'task_id' => $this->task->id,
|
|
'task_pid' => $this->task->id, 'userid' => $user->userid, 'owner' => 1,
|
|
])->save();
|
|
}
|
|
ProjectTask::updated(fn ($task) => ProjectTaskHandoffRecord::updated($task));
|
|
Base::setting('system', [
|
|
'project_task_handoff' => 'open', 'project_task_handoff_role' => 'owner',
|
|
'project_task_handoff_candidates' => 'department', 'project_task_handoff_adjust' => 'department',
|
|
'project_task_handoff_note' => 'optional', 'department_owner_project_view' => 'open',
|
|
], true);
|
|
}
|
|
|
|
protected function tearDown(): void
|
|
{
|
|
RequestContext::clean();
|
|
Model::setEventDispatcher($this->originalDispatcher);
|
|
parent::tearDown();
|
|
}
|
|
|
|
private function user(string $name): User
|
|
{
|
|
$user = User::createInstance(['email' => uniqid('handoff_' . $name) . '@test.local', 'nickname' => $name, 'identity' => ['normal'], 'bot' => 0]);
|
|
$user->save();
|
|
return $user;
|
|
}
|
|
|
|
private function login(User $user): void
|
|
{
|
|
RequestContext::clean();
|
|
$doo = Mockery::mock(DooSo::class);
|
|
$doo->shouldReceive('userId')->andReturn($user->userid);
|
|
$doo->shouldReceive('translate')->andReturnUsing(fn ($value) => $value);
|
|
RequestContext::save('doo_instance', $doo);
|
|
RequestContext::save('auth', $user);
|
|
}
|
|
|
|
public function test_department_owner_candidates_and_protected_people(): void
|
|
{
|
|
$policy = ProjectTaskHandoffService::policy(ProjectTaskHandoffService::task($this->task->id));
|
|
$this->assertEqualsCanonicalizing([$this->inside->userid, $this->receiver->userid], $policy['candidates']);
|
|
$this->assertSame([$this->outside->userid], $policy['protected']);
|
|
}
|
|
|
|
public function test_project_wide_options_do_not_add_nonmembers(): void
|
|
{
|
|
Base::setting('system', ['project_task_handoff_candidates' => 'project', 'project_task_handoff_adjust' => 'all'], true);
|
|
$policy = ProjectTaskHandoffService::policy(ProjectTaskHandoffService::task($this->task->id));
|
|
$this->assertCount(3, $policy['candidates']);
|
|
$this->assertNotContains($this->leader->userid, $policy['candidates']);
|
|
$this->assertSame([], $policy['protected']);
|
|
}
|
|
|
|
public function test_deputy_requires_explicit_authorization(): void
|
|
{
|
|
$deputy = $this->user('deputy');
|
|
DB::table('user_department_owners')->insert(['department_id' => $this->department->id, 'userid' => $deputy->userid]);
|
|
$this->login($deputy);
|
|
$this->expectExceptionMessage('无指派权限');
|
|
ProjectTaskHandoffService::policy(ProjectTaskHandoffService::task($this->task->id));
|
|
}
|
|
|
|
public function test_authorized_deputy_can_assign(): void
|
|
{
|
|
$deputy = $this->user('deputy');
|
|
DB::table('user_department_owners')->insert(['department_id' => $this->department->id, 'userid' => $deputy->userid]);
|
|
Base::setting('system', ['project_task_handoff_role' => 'managers'], true);
|
|
$this->login($deputy);
|
|
$this->assertContains($this->receiver->userid, ProjectTaskHandoffService::policy(ProjectTaskHandoffService::task($this->task->id))['candidates']);
|
|
}
|
|
|
|
public function test_department_authority_does_not_expose_private_tasks(): void
|
|
{
|
|
$this->task->visibility = 2;
|
|
$this->task->save();
|
|
$this->expectException(ApiException::class);
|
|
ProjectTaskHandoffService::task($this->task->id);
|
|
}
|
|
|
|
public function test_switch_off_denies_reads_without_deleting_records(): void
|
|
{
|
|
ProjectTaskHandoffRecord::created($this->task);
|
|
Base::setting('system', ['project_task_handoff' => 'close'], true);
|
|
$this->assertSame(1, ProjectTaskHandoff::where('task_id', $this->task->id)->count());
|
|
$this->expectExceptionMessage('任务流转未开启');
|
|
ProjectTaskHandoffService::task($this->task->id);
|
|
}
|
|
|
|
public function test_project_owner_keeps_original_permission_when_department_assignment_is_off(): void
|
|
{
|
|
Base::setting('system', ['project_task_handoff_role' => 'close'], true);
|
|
$this->login($this->outside);
|
|
$policy = ProjectTaskHandoffService::policy(ProjectTaskHandoffService::task($this->task->id));
|
|
$this->assertCount(3, $policy['candidates']);
|
|
$this->assertSame([], $policy['protected']);
|
|
}
|
|
|
|
public function test_nested_updates_form_one_event_with_note(): void
|
|
{
|
|
ProjectTaskHandoffRecord::track($this->task, 'assign', function () {
|
|
ProjectTaskHandoffRecord::track($this->task, 'flow', function () {
|
|
ProjectTaskUser::whereTaskId($this->task->id)->whereUserid($this->inside->userid)->update(['userid' => $this->receiver->userid]);
|
|
$this->task->flow_item_id = 123;
|
|
$this->task->flow_item_name = 'progress|Processing|';
|
|
$this->task->save();
|
|
});
|
|
}, 'Instruction');
|
|
$events = ProjectTaskHandoff::where('task_id', $this->task->id)->get();
|
|
$this->assertCount(1, $events);
|
|
$record = $events->first()->record;
|
|
$this->assertSame('Instruction', $record['note']);
|
|
$this->assertContains($this->inside->userid, $record['before']['owners']);
|
|
$this->assertContains($this->receiver->userid, $record['after']['owners']);
|
|
$this->assertSame(123, (int)$record['after']['flow_item_id']);
|
|
$this->assertFalse(ProjectTaskHandoffRecord::active($this->task->id));
|
|
}
|
|
|
|
public function test_failed_change_rolls_back_and_clears_recording_context(): void
|
|
{
|
|
try {
|
|
ProjectTaskHandoffRecord::track($this->task, 'assign', function () {
|
|
ProjectTaskUser::whereTaskId($this->task->id)->delete();
|
|
throw new ApiException('Test rollback');
|
|
}, 'Instruction');
|
|
$this->fail('Expected failure');
|
|
} catch (ApiException $e) {
|
|
$this->assertSame('Test rollback', $e->getMessage());
|
|
}
|
|
$this->assertCount(2, ProjectTaskHandoffRecord::owners($this->task->id));
|
|
$this->assertSame(0, ProjectTaskHandoff::where('task_id', $this->task->id)->count());
|
|
$this->assertFalse(ProjectTaskHandoffRecord::active($this->task->id));
|
|
}
|
|
|
|
public function test_unrelated_edits_do_not_create_records_and_bulk_archival_does(): void
|
|
{
|
|
$this->task->name = 'Renamed';
|
|
$this->task->save();
|
|
$this->assertSame(0, ProjectTaskHandoff::where('task_id', $this->task->id)->count());
|
|
ProjectTask::whereKey($this->task->id)->change(['archived_at' => now()]);
|
|
$this->assertSame(1, ProjectTaskHandoff::where('task_id', $this->task->id)->count());
|
|
}
|
|
|
|
public function test_project_exit_records_owner_removal(): void
|
|
{
|
|
ProjectUser::whereProjectId($this->project->id)->whereUserid($this->inside->userid)->first()->exitProject();
|
|
$event = ProjectTaskHandoff::where('task_id', $this->task->id)->first();
|
|
$this->assertSame('member_exit', $event->source);
|
|
$this->assertSame([$this->outside->userid], $event->record['after']['owners']);
|
|
}
|
|
|
|
public function test_account_transfer_records_actual_owners(): void
|
|
{
|
|
ProjectTaskUser::transfer($this->inside->userid, $this->receiver->userid);
|
|
$event = ProjectTaskHandoff::where('task_id', $this->task->id)->first();
|
|
$this->assertSame('transfer', $event->source);
|
|
$this->assertContains($this->receiver->userid, $event->record['after']['owners']);
|
|
}
|
|
|
|
public function test_new_request_does_not_inherit_context(): void
|
|
{
|
|
$request = request();
|
|
RequestContext::save('handoff-isolation-test', 'first');
|
|
$this->assertSame('first', RequestContext::get('handoff-isolation-test'));
|
|
app()->instance('request', Request::create('/api/projecttaskhandoff/lists'));
|
|
$this->assertNull(RequestContext::get('handoff-isolation-test'));
|
|
RequestContext::clean();
|
|
app()->instance('request', $request);
|
|
$this->assertSame('first', RequestContext::get('handoff-isolation-test'));
|
|
}
|
|
|
|
public function test_assignment_uses_existing_owner_update_and_saves_note_atomically(): void
|
|
{
|
|
$task = ProjectTaskHandoffFixture::find($this->task->id);
|
|
$options = ProjectTaskHandoffService::options(ProjectTaskHandoffService::task($task->id));
|
|
$data = ProjectTaskHandoffService::assign($task, [$this->outside->userid, $this->receiver->userid], $options['version'], 'Please review');
|
|
$this->assertSame($task->id, $data['id']);
|
|
$event = ProjectTaskHandoff::where('task_id', $task->id)->sole();
|
|
$this->assertSame('assign', $event->source);
|
|
$this->assertSame('Please review', $event->record['note']);
|
|
$this->assertEqualsCanonicalizing([$this->outside->userid, $this->receiver->userid], ProjectTaskHandoffRecord::owners($task->id));
|
|
$this->expectExceptionMessage('负责人已发生变化,请刷新后重试');
|
|
ProjectTaskHandoffService::assign($task, [$this->outside->userid, $this->inside->userid], $options['version'], 'Stale');
|
|
}
|
|
|
|
public function test_empty_optional_note_passes_controller_validation_after_middleware(): void
|
|
{
|
|
foreach ([[], ['note' => ''], ['note' => ' '], ['note' => null]] as $note) {
|
|
$this->assertAssignmentRequestMessage($note, 'stale', '负责人已发生变化,请刷新后重试');
|
|
}
|
|
}
|
|
|
|
public function test_empty_required_note_still_requires_instruction(): void
|
|
{
|
|
Base::setting('system', ['project_task_handoff_note' => 'required'], true);
|
|
$version = ProjectTaskHandoffService::version($this->task);
|
|
$this->assertAssignmentRequestMessage(['note' => ''], $version, '请填写指派留言');
|
|
}
|
|
|
|
public function test_non_string_note_is_still_rejected(): void
|
|
{
|
|
foreach ([[], 123, false] as $note) {
|
|
$this->assertAssignmentRequestMessage(['note' => $note], 'stale', '指派参数无效');
|
|
}
|
|
}
|
|
|
|
private function assertAssignmentRequestMessage(array $note, string $version, string $message): void
|
|
{
|
|
$original = request();
|
|
$request = Request::create('/api/projecttaskhandoff/assign', 'POST', [], [], [],
|
|
['CONTENT_TYPE' => 'OPTIONS, application/json'], json_encode(array_merge([
|
|
'task_id' => $this->task->id,
|
|
'owners' => [$this->outside->userid, $this->receiver->userid],
|
|
'version' => $version,
|
|
], $note)));
|
|
try {
|
|
$result = (new Pipeline(app()))->send($request)->through([
|
|
TrimStrings::class,
|
|
ConvertEmptyStringsToNull::class,
|
|
])->then(function ($request) {
|
|
RequestFacade::swap($request);
|
|
$this->login($this->leader);
|
|
return (new ProjectTaskHandoffController())->assign();
|
|
});
|
|
$this->assertSame(0, $result['ret']);
|
|
$this->assertSame($message, $result['msg']);
|
|
} catch (ApiException $e) {
|
|
$this->assertSame($message, $e->getMessage());
|
|
} finally {
|
|
RequestFacade::swap($original);
|
|
$this->login($this->leader);
|
|
}
|
|
}
|
|
|
|
public function test_original_owner_edit_is_recorded_with_feature_disabled(): void
|
|
{
|
|
Base::setting('system', ['project_task_handoff' => 'close'], true);
|
|
$task = ProjectTaskHandoffFixture::find($this->task->id);
|
|
$task->updateTask(['owner' => [$this->outside->userid, $this->receiver->userid]]);
|
|
$event = ProjectTaskHandoff::where('task_id', $task->id)->sole();
|
|
$this->assertSame('update', $event->source);
|
|
$this->assertSame('', $event->record['note']);
|
|
}
|
|
|
|
public function test_archived_task_cannot_be_assigned(): void
|
|
{
|
|
$this->task->archived_at = now();
|
|
$this->task->save();
|
|
$this->expectExceptionMessage('当前任务不可指派');
|
|
ProjectTaskHandoffService::policy(ProjectTaskHandoffService::task($this->task->id));
|
|
}
|
|
|
|
public function test_closing_project_department_view_revokes_access(): void
|
|
{
|
|
$this->project->department_owner_view = 'close';
|
|
$this->project->save();
|
|
$this->expectException(ApiException::class);
|
|
ProjectTaskHandoffService::task($this->task->id);
|
|
}
|
|
|
|
public function test_workflow_auto_assignment_is_one_record(): void
|
|
{
|
|
$flow = ProjectFlow::createInstance(['project_id' => $this->project->id, 'name' => 'Test flow']);
|
|
$flow->save();
|
|
$node = ProjectFlowItem::createInstance([
|
|
'project_id' => $this->project->id, 'flow_id' => $flow->id,
|
|
'name' => 'Processing', 'status' => 'progress', 'usertype' => 'replace',
|
|
'userids' => [$this->receiver->userid],
|
|
]);
|
|
$node->save();
|
|
$task = ProjectTaskHandoffFixture::find($this->task->id);
|
|
$task->updateTask(['flow_item_id' => $node->id]);
|
|
$event = ProjectTaskHandoff::where('task_id', $task->id)->sole();
|
|
$this->assertSame('flow', $event->source);
|
|
$this->assertSame([$this->receiver->userid], $event->record['after']['owners']);
|
|
$this->assertSame($node->id, (int)$event->record['after']['flow_item_id']);
|
|
}
|
|
|
|
public function test_copy_starts_its_own_history(): void
|
|
{
|
|
ProjectTaskHandoffRecord::created($this->task);
|
|
$copy = $this->task->copyTask();
|
|
$event = ProjectTaskHandoff::where('task_id', $copy->id)->sole();
|
|
$this->assertSame('copy', $event->source);
|
|
$this->assertNull($event->record['before']);
|
|
$this->assertSame(ProjectTaskHandoffRecord::owners($this->task->id), $event->record['after']['owners']);
|
|
}
|
|
|
|
public function test_account_transfer_still_handles_deleted_tasks(): void
|
|
{
|
|
$this->task->delete();
|
|
ProjectTaskUser::transfer($this->inside->userid, $this->receiver->userid);
|
|
$this->assertContains($this->receiver->userid, ProjectTaskHandoffRecord::owners($this->task->id));
|
|
}
|
|
}
|
|
|
|
// Exercise database changes while keeping notifications outside these transactional tests.
|
|
class ProjectTaskHandoffFixture extends ProjectTask
|
|
{
|
|
protected $table = 'project_tasks';
|
|
|
|
public function taskPush($userids, int $type, string $suffix = '') {}
|
|
public function syncDialogUser() {}
|
|
public function pushMsg($action, $data = null, $userid = null, $ignoreSelf = true) {}
|
|
public function pushMsgVisibleRemove(array $userids = []) {}
|
|
}
|