169 lines
8.2 KiB
PHP
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);
|
|
}
|
|
}
|