Files

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();
}
}