Files
Investidor-V2/tests/Feature/CrmTaskCreationTest.php
T

169 lines
8.2 KiB
PHP

<?php
namespace Tests\Feature;
use App\Models\CrmTask;
use App\Models\Lead;
use App\Models\User;
use App\Services\CrmTaskCreation;
use Carbon\CarbonImmutable;
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 Inertia\Testing\AssertableInertia as Assert;
use Tests\Support\AuthenticatesQaActors;
use Tests\TestCase;
final class CrmTaskCreationTest extends TestCase
{
use AuthenticatesQaActors;
use RefreshDatabase;
private User $manager;
private Lead $lead;
protected function setUp(): void
{
parent::setUp();
$this->withoutVite();
Http::fake();
Mail::fake();
$this->manager = User::factory()->create();
config(['portal.managers' => [$this->manager->email], 'suitecrm.enabled' => false, 'investmoz_ai.enabled' => false]);
$this->lead = Lead::create(['title' => 'SYNTHETIC task creation lead', 'status' => 'new', 'owner_user_id' => $this->manager->id])->fresh();
$this->actingAsWithMfa($this->manager);
}
private function url(): string
{
return '/management/crm/leads/'.$this->lead->id.'/tasks';
}
/** @return array{client_key:string,title:string,due_at:string,assignee_user_id:int} */
private function command(): array
{
return ['client_key' => (string) Str::uuid(), 'title' => 'SYNTHETIC next action', 'due_at' => now()->addDay()->toIso8601String(), 'assignee_user_id' => $this->manager->id];
}
public function test_repeating_the_same_creation_does_not_create_a_second_task(): void
{
$command = $this->command();
$this->post($this->url(), $command)->assertRedirect();
$events = DB::table('audit_events')->where('entity_type', 'tasks')->count();
$this->post($this->url(), $command)->assertRedirect();
$this->assertDatabaseCount('tasks', 1);
$this->assertSame($events, DB::table('audit_events')->where('entity_type', 'tasks')->count());
}
public function test_retry_uses_the_original_receipt_after_deadline_expiry_and_assignee_retirement(): void
{
$other = User::factory()->create();
config(['portal.managers' => [$this->manager->email, $other->email]]);
$command = [...$this->command(), 'assignee_user_id' => $other->id];
$this->post($this->url(), $command)->assertRedirect();
$events = DB::table('audit_events')->where('entity_type', 'tasks')->count();
config(['portal.managers' => [$this->manager->email]]);
$this->travel(2)->days();
$this->post($this->url(), $command)->assertRedirect();
$this->assertDatabaseCount('tasks', 1);
$this->assertSame($events, DB::table('audit_events')->where('entity_type', 'tasks')->count());
}
public function test_a_used_key_cannot_create_a_modified_task(): void
{
$command = $this->command();
$this->post($this->url(), $command)->assertRedirect();
$events = DB::table('audit_events')->where('entity_type', 'tasks')->count();
$this->postJson($this->url(), [...$command, 'title' => 'SYNTHETIC changed'])->assertConflict();
$this->assertDatabaseCount('tasks', 1);
$this->assertSame($events, DB::table('audit_events')->where('entity_type', 'tasks')->count());
}
public function test_validation_does_not_consume_the_key_or_write_a_task(): void
{
$command = $this->command();
$this->postJson($this->url(), [...$command, 'due_at' => now()->subDay()->toIso8601String(), 'assignee_user_id' => 999999])->assertUnprocessable()->assertJsonValidationErrors(['due_at', 'assignee_user_id']);
$this->assertDatabaseCount('tasks', 0);
$this->assertDatabaseCount('audit_events', 0);
$this->post($this->url(), $command)->assertRedirect();
$this->assertDatabaseCount('tasks', 1);
}
public function test_equivalent_offsets_and_whitespace_replay_the_same_canonical_values(): void
{
$command = $this->command();
$this->post($this->url(), $command)->assertRedirect();
$this->post($this->url(), [...$command, 'client_key' => strtoupper($command['client_key']), 'title' => ' '.$command['title'].' ', 'due_at' => CarbonImmutable::parse($command['due_at'])->setTimezone('Africa/Maputo')->toIso8601String()])->assertRedirect();
$this->assertDatabaseCount('tasks', 1);
}
public function test_receipts_are_scoped_to_the_current_actor_and_lead_without_raw_audit_payloads(): void
{
$command = $this->command();
$this->post($this->url(), $command)->assertRedirect();
$other = User::factory()->create();
config(['portal.managers' => [$this->manager->email, $other->email]]);
$this->actingAsWithMfa($other);
$this->post($this->url(), $command)->assertRedirect();
$secondLead = Lead::create(['title' => 'SYNTHETIC second', 'status' => 'new']);
app(CrmTaskCreation::class)->create($other, $secondLead, $command, [$this->manager->id]);
$this->assertDatabaseCount('tasks', 3);
$this->get('/management/crm/leads/'.$this->lead->id)->assertOk()->assertInertia(fn (Assert $page) => $page->component('crm/lead')
->where('taskCreation.actor_id', $other->id)->where('taskCreation.lead_id', $this->lead->id)
->has('taskCreation.commands', 1)->where('taskCreation.commands.0.key', $command['client_key'])
->missing('taskCreation.commands.0.hash')->missing('taskCreation.commands.0.title')->missing('taskCreation.commands.0.after_data'));
}
public function test_replay_requires_current_management_authority_even_when_the_creation_was_accepted(): void
{
$command = $this->command();
$this->post($this->url(), $command)->assertRedirect();
$events = DB::table('audit_events')->where('entity_type', 'tasks')->count();
config(['portal.managers' => []]);
$this->postJson($this->url(), $command)->assertForbidden();
$this->get('/management/crm/leads/'.$this->lead->id)->assertForbidden();
$this->assertDatabaseCount('tasks', 1);
$this->assertSame($events, DB::table('audit_events')->where('entity_type', 'tasks')->count());
}
public function test_completed_and_deleted_tasks_are_not_restored_by_replay(): void
{
$command = $this->command();
$this->post($this->url(), $command)->assertRedirect();
$task = CrmTask::firstOrFail();
$task->update(['status' => 'done', 'version_no' => 2]);
$this->post($this->url(), $command)->assertRedirect();
$this->assertSame('done', $task->fresh()->status);
$task->delete();
$events = DB::table('audit_events')->where('entity_type', 'tasks')->count();
$this->post($this->url(), $command)->assertRedirect();
$this->assertDatabaseCount('tasks', 1);
$this->assertSoftDeleted($task);
$this->assertSame($events, DB::table('audit_events')->where('entity_type', 'tasks')->count());
$this->get('/management/crm/leads/'.$this->lead->id)->assertInertia(fn (Assert $page) => $page->has('lead.tasks', 0)->has('taskCreation.commands', 1));
}
public function test_legacy_callers_without_a_key_keep_the_existing_creation_contract(): void
{
$command = $this->command();
unset($command['client_key']);
$this->post($this->url(), $command)->assertRedirect();
$this->post($this->url(), $command)->assertRedirect();
$this->assertDatabaseCount('tasks', 2);
$this->assertSame(0, DB::table('audit_events')->where('source_system', 'portal-crm-task-creation')->count());
}
public function test_malformed_keys_and_unapproved_assignees_are_rejected_without_writes(): void
{
$command = $this->command();
$this->postJson($this->url(), [...$command, 'client_key' => 'invalid'])->assertUnprocessable()->assertJsonValidationErrors('client_key');
$unapproved = User::factory()->create();
$this->postJson($this->url(), [...$command, 'assignee_user_id' => $unapproved->id])->assertUnprocessable()->assertJsonValidationErrors('assignee_user_id');
$this->assertDatabaseCount('tasks', 0);
$this->assertDatabaseCount('audit_events', 0);
}
}