seed(RolePermissionSeeder::class); } public function test_task_lists_and_direct_access_are_scoped_by_role_and_team(): void { $admin = $this->user('admin'); [$supervisor, $agent] = $this->teamUsers('Own'); [$otherSupervisor, $otherAgent] = $this->teamUsers('Other'); $own = $this->task($supervisor, $agent, 'Own team task', 'team'); $other = $this->task($otherSupervisor, $otherAgent, 'Other team task', 'team'); $this->actingAs($admin)->getJson('/api/tasks')->assertOk()->assertJsonPath('meta.total', 2); $this->actingAs($supervisor)->getJson('/api/tasks')->assertOk()->assertJsonPath('meta.total', 1)->assertJsonPath('data.0.id', $own->id); $this->actingAs($agent)->getJson('/api/tasks')->assertOk()->assertJsonPath('meta.total', 1)->assertJsonPath('data.0.id', $own->id); $this->actingAs($agent)->getJson("/api/tasks/{$other->id}")->assertForbidden(); } public function test_cross_team_inactive_assignee_and_unauthorized_entity_are_rejected(): void { [$supervisor, $agent] = $this->teamUsers('Own'); [, $otherAgent] = $this->teamUsers('Other'); $task = $this->task($supervisor, $agent, 'Scoped task', 'team'); $this->actingAs($supervisor)->postJson("/api/tasks/{$task->id}/assign", [ 'assigned_to' => $otherAgent->id, 'version' => 1, ])->assertForbidden(); $inactive = $this->user('agent', ['is_active' => false]); $this->actingAs($supervisor)->postJson("/api/tasks/{$task->id}/assign", [ 'assigned_to' => $inactive->id, 'version' => 1, ])->assertUnprocessable()->assertJsonPath('code', 'VALIDATION_FAILED'); $foreignLead = Lead::create([ 'first_name' => 'Other', 'last_name' => 'Lead', 'company' => 'Foreign', 'phone' => '02112345678', 'assigned_to' => $otherAgent->id, ]); $this->actingAs($agent)->postJson('/api/tasks', [ 'subject' => 'Unauthorized relation', 'taskable_type' => 'lead', 'taskable_id' => $foreignLead->id, ])->assertForbidden(); } public function test_task_lifecycle_optimistic_lock_and_transactional_bulk_actions(): void { [$supervisor, $agent] = $this->teamUsers('Lifecycle'); $created = $this->actingAs($supervisor)->postJson('/api/tasks', [ 'subject' => 'Call customer', 'assigned_to' => $agent->id, 'priority' => 'high', 'visibility' => 'team', 'due_at' => now()->addDay()->toISOString(), ])->assertCreated()->assertJsonPath('data.status', 'open'); $taskId = $created->json('data.id'); $started = $this->actingAs($agent)->postJson("/api/tasks/{$taskId}/start", ['version' => 1]) ->assertOk()->assertJsonPath('data.status', 'in_progress'); $this->actingAs($agent)->patchJson("/api/tasks/{$taskId}", [ 'subject' => 'Stale edit', 'version' => 1, ])->assertForbidden(); $this->actingAs($supervisor)->patchJson("/api/tasks/{$taskId}", [ 'subject' => 'Stale creator edit', 'version' => 1, ])->assertStatus(409)->assertJsonPath('code', 'VERSION_CONFLICT'); $completed = $this->actingAs($agent)->postJson("/api/tasks/{$taskId}/complete", ['version' => $started->json('data.version')]) ->assertOk()->assertJsonPath('data.status', 'done'); $this->assertNotNull(Task::find($taskId)->completed_at); $reopened = $this->actingAs($agent)->postJson("/api/tasks/{$taskId}/reopen", ['version' => $completed->json('data.version')]) ->assertOk()->assertJsonPath('data.status', 'open'); $this->assertNull(Task::find($taskId)->completed_at); $second = $this->task($supervisor, $agent, 'Second', 'team'); $this->actingAs($supervisor)->postJson('/api/tasks/bulk-complete', [ 'task_ids' => [$taskId, $second->id], ])->assertOk()->assertJsonCount(2, 'data'); $this->assertSame(2, Task::whereIn('id', [$taskId, $second->id])->where('status', 'done')->count()); $this->assertDatabaseHas('activity_logs', ['action' => 'task_bulk_completed', 'subject_id' => $second->id]); $this->assertDatabaseHas('internal_notifications', ['user_id' => $agent->id, 'type' => 'task_assigned']); } private function task(User $creator, User $assignee, string $subject, string $visibility): Task { return Task::create([ 'subject' => $subject, 'assigned_to' => $assignee->id, 'assigned_by' => $creator->id, 'created_by' => $creator->id, 'priority' => 'normal', 'status' => 'open', 'visibility' => $visibility, ]); } private function teamUsers(string $name): array { $supervisor = $this->user('supervisor'); $agent = $this->user('agent'); $team = Team::create(['name' => $name, 'supervisor_id' => $supervisor->id, 'is_active' => true]); $team->members()->attach([$supervisor->id, $agent->id]); return [$supervisor, $agent]; } private function user(string $role, array $attributes = []): User { $user = User::factory()->create(array_merge(['is_active' => true], $attributes)); $user->assignRole($role); return $user; } }