200 lines
11 KiB
PHP
200 lines
11 KiB
PHP
<?php
|
|
|
|
namespace Tests\Feature;
|
|
|
|
use App\Models\CrmTask;
|
|
use App\Models\Lead;
|
|
use App\Models\User;
|
|
use App\Services\Ai\AssistiveRules;
|
|
use App\Services\VirtualDesk;
|
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
|
use Illuminate\Support\Facades\DB;
|
|
use Illuminate\Support\Facades\Http;
|
|
use Illuminate\Support\Facades\Mail;
|
|
use Illuminate\Support\Str;
|
|
use Tests\Support\AuthenticatesQaActors;
|
|
use Tests\TestCase;
|
|
|
|
final class CrmNextActionTest extends TestCase
|
|
{
|
|
use AuthenticatesQaActors;
|
|
use RefreshDatabase;
|
|
|
|
private User $manager;
|
|
|
|
private User $owner;
|
|
|
|
private Lead $lead;
|
|
|
|
protected function setUp(): void
|
|
{
|
|
parent::setUp();
|
|
$this->withoutVite();
|
|
Http::fake();
|
|
Mail::fake();
|
|
$this->manager = User::factory()->create();
|
|
$this->owner = User::factory()->create();
|
|
config(['portal.managers' => [$this->manager->email], 'suitecrm.enabled' => false, 'investmoz_ai.enabled' => false]);
|
|
$this->lead = Lead::create(['title' => 'SYNTHETIC next action lead', 'status' => 'new', 'owner_user_id' => $this->manager->id])->fresh();
|
|
$this->actingAsWithMfa($this->manager);
|
|
}
|
|
|
|
private function caseId(): string
|
|
{
|
|
return app(VirtualDesk::class)->createCase($this->owner, ['title' => 'SYNTHETIC next action dossier', 'client_key' => (string) Str::uuid(), 'reuse_consent' => true])->id;
|
|
}
|
|
|
|
private function task(?string $case = null, array $changes = []): CrmTask
|
|
{
|
|
$data = ['lead_id' => $this->lead->id, 'title' => 'SYNTHETIC next action task', 'status' => 'open', 'due_at' => now()->addDay(), 'assignee_user_id' => $this->manager->id];
|
|
if ($case !== null) {
|
|
$data = [...$data, 'desk_case_id' => $case, 'accepted_by' => $this->owner->id, 'accepted_at' => now(), 'guidance_key' => 'synthetic-'.Str::uuid(), 'data_classification' => 'confidential', 'external_sync_status' => 'pending_authorization'];
|
|
}
|
|
|
|
return CrmTask::create([...$data, ...$changes])->fresh();
|
|
}
|
|
|
|
private function state(string $case, string $state): void
|
|
{
|
|
app(VirtualDesk::class)->changeCaseState($this->owner, $case, ['version' => (int) DB::table('desk_cases')->where('id', $case)->value('version'), 'state' => $state, 'reason' => 'SYNTHETIC owner changes dossier availability', 'client_key' => (string) Str::uuid()]);
|
|
}
|
|
|
|
private function qualification(string $status = 'qualified'): array
|
|
{
|
|
return ['version' => $this->lead->fresh()->version_no, 'owner_user_id' => $this->manager->id, 'status' => $status, 'qualification_reason' => 'SYNTHETIC internal qualification reason'];
|
|
}
|
|
|
|
public function test_a_suspended_future_task_cannot_qualify_the_lead(): void
|
|
{
|
|
$case = $this->caseId();
|
|
$task = $this->task($case);
|
|
$this->state($case, 'closed');
|
|
$before = [$this->lead->fresh()->getAttributes(), $task->fresh()->getAttributes(), DB::table('audit_events')->count()];
|
|
$this->patchJson('/management/crm/leads/'.$this->lead->id, $this->qualification())->assertUnprocessable()->assertJsonValidationErrors('status');
|
|
$this->assertSame($before, [$this->lead->fresh()->getAttributes(), $task->fresh()->getAttributes(), DB::table('audit_events')->count()]);
|
|
}
|
|
|
|
public function test_a_suspended_alternative_cannot_allow_finishing_the_last_available_task(): void
|
|
{
|
|
$current = $this->task();
|
|
$case = $this->caseId();
|
|
$alternative = $this->task($case);
|
|
$this->state($case, 'closed');
|
|
$this->patchJson('/management/crm/leads/'.$this->lead->id.'/tasks/'.$current->id, ['version' => 1, 'status' => 'done'])->assertUnprocessable()->assertJsonValidationErrors('status');
|
|
$this->assertSame('open', $current->fresh()->status);
|
|
$this->assertSame(1, $current->fresh()->version_no);
|
|
$this->assertSame('open', $alternative->fresh()->status);
|
|
}
|
|
|
|
public function test_guidance_cannot_bypass_the_next_action_rule_for_a_task_linked_to_an_active_lead(): void
|
|
{
|
|
$case = $this->caseId();
|
|
$task = $this->task($case);
|
|
$events = DB::table('desk_events')->count();
|
|
$this->postJson('/balcao-virtual/dossiers/'.$case.'/tarefas/'.$task->id, ['version' => 1, 'status' => 'done', 'reason' => 'SYNTHETIC attempted final response'])->assertUnprocessable()->assertJsonValidationErrors('status');
|
|
$this->assertSame('open', $task->fresh()->status);
|
|
$this->assertSame(1, $task->fresh()->version_no);
|
|
$this->assertSame($events, DB::table('desk_events')->count());
|
|
}
|
|
|
|
public function test_owner_reopening_restores_eligibility_without_rewriting_task_states(): void
|
|
{
|
|
$current = $this->task();
|
|
$case = $this->caseId();
|
|
$alternative = $this->task($case);
|
|
$before = $alternative->getAttributes();
|
|
$this->state($case, 'closed');
|
|
$this->state($case, 'open');
|
|
$this->assertSame($before, $alternative->fresh()->getAttributes());
|
|
$this->patchJson('/management/crm/leads/'.$this->lead->id, $this->qualification())->assertRedirect();
|
|
$this->patchJson('/management/crm/leads/'.$this->lead->id.'/tasks/'.$current->id, ['version' => 1, 'status' => 'done'])->assertRedirect();
|
|
$this->postJson('/balcao-virtual/dossiers/'.$case.'/tarefas/'.$alternative->id, ['version' => 1, 'status' => 'cancelled', 'reason' => 'SYNTHETIC last alternative cannot finish'])->assertUnprocessable()->assertJsonValidationErrors('status');
|
|
$this->assertSame('open', $alternative->fresh()->status);
|
|
}
|
|
|
|
public function test_guidance_can_finish_with_another_available_future_task_and_keeps_its_audit_route(): void
|
|
{
|
|
$case = $this->caseId();
|
|
$current = $this->task($case);
|
|
$other = $this->task(null, ['status' => 'in_progress']);
|
|
$this->postJson('/balcao-virtual/dossiers/'.$case.'/tarefas/'.$current->id, ['version' => 1, 'status' => 'done', 'reason' => 'SYNTHETIC response with next action'])->assertOk();
|
|
$this->assertSame('done', $current->fresh()->status);
|
|
$this->assertSame(2, $current->fresh()->version_no);
|
|
$this->assertSame('in_progress', $other->fresh()->status);
|
|
$this->assertSame(1, DB::table('desk_events')->where('action', 'guidance_task_updated')->count());
|
|
$this->postJson('/balcao-virtual/dossiers/'.$case.'/tarefas/'.$current->id, ['version' => 1, 'status' => 'open', 'reason' => 'SYNTHETIC stale response'])->assertConflict();
|
|
}
|
|
|
|
public function test_expired_undated_deleted_and_other_lead_tasks_are_not_future_replacements_but_disqualification_still_works(): void
|
|
{
|
|
$current = $this->task(null, ['due_at' => now()->subDay()]);
|
|
$this->task(null, ['due_at' => null]);
|
|
$this->task()->delete();
|
|
$other = Lead::create(['title' => 'SYNTHETIC different lead', 'status' => 'new']);
|
|
$this->task(null, ['lead_id' => $other->id]);
|
|
$this->patchJson('/management/crm/leads/'.$this->lead->id, $this->qualification())->assertUnprocessable()->assertJsonValidationErrors('status');
|
|
$this->patchJson('/management/crm/leads/'.$this->lead->id, $this->qualification('disqualified'))->assertRedirect();
|
|
$this->patchJson('/management/crm/leads/'.$this->lead->id.'/tasks/'.$current->id, ['version' => 1, 'status' => 'done'])->assertRedirect();
|
|
}
|
|
|
|
public function test_guidance_without_a_crm_lead_keeps_existing_permissions_and_owner_closure(): void
|
|
{
|
|
$case = $this->caseId();
|
|
$task = $this->task($case, ['lead_id' => null, 'assignee_user_id' => $this->owner->id]);
|
|
$this->actingAs($this->owner)->postJson('/balcao-virtual/dossiers/'.$case.'/tarefas/'.$task->id, ['version' => 1, 'status' => 'done', 'reason' => 'SYNTHETIC standalone investor task'])->assertOk();
|
|
$this->state($case, 'closed');
|
|
$this->postJson('/balcao-virtual/dossiers/'.$case.'/tarefas/'.$task->id, ['version' => 2, 'status' => 'open', 'reason' => 'SYNTHETIC closed case remains read only'])->assertConflict();
|
|
$this->assertSame('done', $task->fresh()->status);
|
|
}
|
|
|
|
public function test_new_rule_does_not_grant_crm_or_dossier_access(): void
|
|
{
|
|
$case = $this->caseId();
|
|
$task = $this->task($case);
|
|
$other = User::factory()->create();
|
|
$this->actingAs($other)->patchJson('/management/crm/leads/'.$this->lead->id.'/tasks/'.$task->id, ['version' => 1, 'status' => 'done'])->assertForbidden();
|
|
$this->postJson('/balcao-virtual/dossiers/'.$case.'/tarefas/'.$task->id, ['version' => 1, 'status' => 'done', 'reason' => 'SYNTHETIC unrelated account'])->assertForbidden();
|
|
$this->assertSame('open', $task->fresh()->status);
|
|
}
|
|
|
|
public function test_archived_lead_does_not_block_an_existing_authorized_guidance_response(): void
|
|
{
|
|
$case = $this->caseId();
|
|
$task = $this->task($case);
|
|
$this->lead->delete();
|
|
$this->postJson('/balcao-virtual/dossiers/'.$case.'/tarefas/'.$task->id, ['version' => 1, 'status' => 'done', 'reason' => 'SYNTHETIC archived lead retains dossier response'])->assertOk();
|
|
$this->assertSame('done', $task->fresh()->status);
|
|
$this->assertSoftDeleted('leads', ['id' => $this->lead->id]);
|
|
}
|
|
|
|
public function test_unlinked_crm_does_not_depend_on_optional_desk_tables(): void
|
|
{
|
|
$first = $this->task();
|
|
$this->task();
|
|
DB::statement('ALTER TABLE desk_cases RENAME TO desk_cases_qa_unavailable');
|
|
try {
|
|
$this->patchJson('/management/crm/leads/'.$this->lead->id, $this->qualification())->assertRedirect();
|
|
$this->patchJson('/management/crm/leads/'.$this->lead->id.'/tasks/'.$first->id, ['version' => 1, 'status' => 'done'])->assertRedirect();
|
|
} finally {
|
|
DB::statement('ALTER TABLE desk_cases_qa_unavailable RENAME TO desk_cases');
|
|
}
|
|
}
|
|
|
|
public function test_read_models_distinguish_suspended_tasks_and_keep_their_history(): void
|
|
{
|
|
$case = $this->caseId();
|
|
$suspended = $this->task($case, ['due_at' => now()->subDay()]);
|
|
$this->state($case, 'closed');
|
|
$available = $this->task(null, ['due_at' => now()->subHour()]);
|
|
$this->get('/management/crm/leads')->assertInertia(fn ($page) => $page->where('leads.data.0.overdue_tasks_count', 1));
|
|
$this->get('/management/crm/leads/'.$this->lead->id)->assertInertia(fn ($page) => $page->where('nextAction.future_tasks', 0)->has('lead.tasks', 2));
|
|
$this->assertSame('open', $suspended->fresh()->status);
|
|
$this->assertSame('open', $available->fresh()->status);
|
|
$available->delete();
|
|
$alerts = app(AssistiveRules::class)->quality();
|
|
$this->assertCount(1, array_filter($alerts, fn ($row) => ($row['rule'] ?? null) === 'missing_next_action' && ($row['record_id'] ?? null) === $this->lead->id));
|
|
Http::assertNothingSent();
|
|
Mail::assertNothingSent();
|
|
}
|
|
}
|