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

233 lines
13 KiB
PHP

<?php
namespace Tests\Feature;
use App\Models\Lead;
use App\Models\User;
use App\Services\CrmTaskPreparations;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Crypt;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Mail;
use Illuminate\Support\Facades\Schema;
use Illuminate\Support\Str;
use Inertia\Testing\AssertableInertia;
use Tests\Support\AuthenticatesQaActors;
use Tests\TestCase;
final class CrmTaskPreparationTest extends TestCase
{
use AuthenticatesQaActors, 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 preparation 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.'/task-preparation';
}
/** @return array<string,mixed> */
private function input(): array
{
return ['version' => 0, 'operation_key' => (string) Str::uuid(), 'snapshot' => [
'values' => ['title' => 'SYNTHETIC partial task', 'due_at' => '', 'assignee_user_id' => ''],
'generation' => ['title' => 1, 'due_at' => 0, 'assignee_user_id' => 0], 'pending' => null,
]];
}
public function test_partial_preparation_is_encrypted_and_recoverable_without_creating_a_task(): void
{
$input = $this->input();
$this->postJson($this->url(), $input)->assertOk()->assertJsonPath('preparation.version', 1)->assertJsonPath('preparation.snapshot', $input['snapshot']);
$stored = DB::table('crm_task_preparations')->first();
$this->assertStringNotContainsString('SYNTHETIC partial task', $stored->snapshot);
$this->assertSame($input['snapshot'], json_decode(Crypt::decryptString($stored->snapshot), true));
$this->assertDatabaseCount('tasks', 0);
$this->assertSame(1, DB::table('audit_events')->where('source_system', 'portal-crm-task-preparation')->count());
$this->assertSame($input['snapshot'], app(CrmTaskPreparations::class)->view($this->manager, $this->lead)['snapshot']);
}
public function test_repeated_checkpoint_is_read_only_and_changed_payload_with_same_key_conflicts(): void
{
$input = $this->input();
$this->postJson($this->url(), $input)->assertOk();
$audit = DB::table('audit_events')->where('source_system', 'portal-crm-task-preparation')->count();
$this->postJson($this->url(), $input)->assertOk()->assertJsonPath('preparation.version', 1);
$this->assertSame($audit, DB::table('audit_events')->where('source_system', 'portal-crm-task-preparation')->count());
$input['snapshot']['values']['title'] = 'SYNTHETIC changed';
$this->postJson($this->url(), $input)->assertConflict();
$this->assertDatabaseCount('crm_task_preparations', 1);
$this->assertSame(1, DB::table('crm_task_preparations')->value('version'));
}
public function test_another_tab_cannot_overwrite_a_newer_preparation_with_a_stale_version(): void
{
$input = $this->input();
$this->postJson($this->url(), $input)->assertOk();
$other = $this->input();
$other['snapshot']['values']['title'] = 'SYNTHETIC competing';
$this->postJson($this->url(), $other)->assertConflict();
$this->assertSame($input['snapshot'], app(CrmTaskPreparations::class)->view($this->manager, $this->lead)['snapshot']);
$other['version'] = 1;
$this->postJson($this->url(), $other)->assertOk()->assertJsonPath('preparation.version', 2);
$this->assertSame($other['snapshot'], app(CrmTaskPreparations::class)->view($this->manager, $this->lead)['snapshot']);
}
public function test_partial_text_and_empty_fields_survive_framework_normalisation(): void
{
$input = $this->input();
$input['snapshot']['values']['title'] = " SYNTHETIC\npartial ";
$this->postJson($this->url(), $input)->assertOk()->assertJsonPath('preparation.snapshot', $input['snapshot']);
$this->assertDatabaseCount('tasks', 0);
}
public function test_a_frozen_attempt_and_later_edits_survive_a_new_authenticated_session(): void
{
$input = $this->input();
$input['snapshot']['pending'] = ['values' => ['title' => 'SYNTHETIC original', 'due_at' => now()->addDay()->toIso8601String(), 'assignee_user_id' => (string) $this->manager->id, 'client_key' => (string) Str::uuid()],
'generation' => ['title' => 1, 'due_at' => 1, 'assignee_user_id' => 0]];
$input['snapshot']['values']['title'] = 'SYNTHETIC later';
$input['snapshot']['generation']['title'] = 2;
$this->postJson($this->url(), $input)->assertOk();
$this->flushSession();
$this->actingAsWithMfa($this->manager->fresh());
$this->get('/management/crm/leads/'.$this->lead->id)->assertOk()->assertInertia(fn (AssertableInertia $page) => $page->where('taskCreation.preparation.snapshot', $input['snapshot']));
$this->assertDatabaseCount('tasks', 0);
}
public function test_preparations_are_private_to_the_current_actor_and_lead(): void
{
$input = $this->input();
$this->postJson($this->url(), $input)->assertOk();
$other = User::factory()->create();
config(['portal.managers' => [$this->manager->email, $other->email]]);
$this->actingAsWithMfa($other);
$this->get('/management/crm/leads/'.$this->lead->id)->assertOk()->assertInertia(fn (AssertableInertia $page) => $page->where('taskCreation.actor_id', $other->id)->where('taskCreation.preparation.snapshot', null));
$this->postJson($this->url(), $this->input())->assertOk();
$second = Lead::create(['title' => 'SYNTHETIC another lead', 'status' => 'new']);
$this->assertNull(app(CrmTaskPreparations::class)->view($other, $second)['snapshot']);
$this->assertDatabaseCount('crm_task_preparations', 2);
}
public function test_current_management_authority_is_required_to_read_and_write_preparation(): void
{
$this->postJson($this->url(), $this->input())->assertOk();
$stored = DB::table('crm_task_preparations')->value('snapshot');
config(['portal.managers' => []]);
$this->get('/management/crm/leads/'.$this->lead->id)->assertForbidden();
$this->postJson($this->url(), $this->input())->assertForbidden();
$this->assertSame($stored, DB::table('crm_task_preparations')->value('snapshot'));
}
public function test_unknown_fields_invalid_nonce_and_generation_are_not_persisted(): void
{
$input = $this->input();
$input['snapshot']['values']['private_extra'] = 'SYNTHETIC unwanted';
$this->postJson($this->url(), $input)->assertUnprocessable();
$input = $this->input();
$input['operation_key'] = 'not-a-uuid';
$this->postJson($this->url(), $input)->assertUnprocessable();
$input = $this->input();
$input['snapshot']['generation']['title'] = -1;
$this->postJson($this->url(), $input)->assertUnprocessable();
$this->assertDatabaseCount('crm_task_preparations', 0);
$this->assertDatabaseCount('tasks', 0);
}
public function test_unreadable_ciphertext_is_preserved_and_does_not_prevent_the_manual_task_path(): void
{
$this->postJson($this->url(), $this->input())->assertOk();
DB::table('crm_task_preparations')->update(['snapshot' => 'SYNTHETIC unreadable ciphertext']);
$this->get('/management/crm/leads/'.$this->lead->id)->assertOk()->assertInertia(fn (AssertableInertia $page) => $page->where('taskCreation.preparation.unavailable', true)->where('taskCreation.preparation.snapshot', null));
$input = $this->input();
$input['version'] = 1;
$this->postJson($this->url(), $input)->assertConflict();
$this->assertSame('SYNTHETIC unreadable ciphertext', DB::table('crm_task_preparations')->value('snapshot'));
$this->post('/management/crm/leads/'.$this->lead->id.'/tasks', ['client_key' => (string) Str::uuid(), 'title' => 'SYNTHETIC manual', 'due_at' => now()->addDay()->toIso8601String(), 'assignee_user_id' => $this->manager->id])->assertRedirect();
$this->assertDatabaseCount('tasks', 1);
}
public function test_uninstalled_preparation_preserves_the_existing_manual_creation_contract(): void
{
Schema::rename('crm_task_preparations', 'crm_task_preparations_not_installed');
$this->get('/management/crm/leads/'.$this->lead->id)->assertOk()->assertInertia(fn (AssertableInertia $page) => $page->where('taskCreation.preparation.installed', false));
$this->postJson($this->url(), $this->input())->assertConflict();
$this->post('/management/crm/leads/'.$this->lead->id.'/tasks', ['title' => 'SYNTHETIC manual', 'due_at' => now()->addDay()->toIso8601String(), 'assignee_user_id' => $this->manager->id])->assertRedirect();
$this->assertDatabaseCount('tasks', 1);
}
public function test_audit_metadata_does_not_copy_the_private_preparation(): void
{
$input = $this->input();
$this->postJson($this->url(), $input)->assertOk();
$event = DB::table('audit_events')->where('source_system', 'portal-crm-task-preparation')->first();
$this->assertSame(['version' => 1], json_decode($event->after_data, true));
$this->assertStringNotContainsString('SYNTHETIC partial task', json_encode($event, JSON_THROW_ON_ERROR));
}
public function test_creation_receipt_and_later_preparation_survive_a_new_session_without_duplicate_tasks(): void
{
$input = $this->input();
$before = $this->lead->getAttributes();
$command = ['title' => 'SYNTHETIC original task', 'due_at' => now()->addYear()->toIso8601String(), 'assignee_user_id' => (string) $this->manager->id, 'client_key' => (string) Str::uuid()];
$input['snapshot']['pending'] = ['values' => $command, 'generation' => ['title' => 1, 'due_at' => 1, 'assignee_user_id' => 0]];
$input['snapshot']['values']['title'] = 'SYNTHETIC next draft';
$input['snapshot']['generation']['title'] = 2;
$this->postJson($this->url(), $input)->assertOk();
$taskUrl = '/management/crm/leads/'.$this->lead->id.'/tasks';
$this->post($taskUrl, $command)->assertRedirect();
$task = DB::table('tasks')->first();
$this->flushSession();
$this->actingAsWithMfa($this->manager->fresh());
$this->get('/management/crm/leads/'.$this->lead->id)->assertOk()->assertInertia(fn (AssertableInertia $page) => $page
->where('taskCreation.preparation.snapshot', $input['snapshot'])->has('taskCreation.commands', 1)
->where('taskCreation.commands.0.key', $command['client_key'])->where('taskCreation.commands.0.task_id', $task->id));
$events = DB::table('audit_events')->where('entity_type', 'tasks')->count();
$this->post($taskUrl, $command)->assertRedirect();
$this->assertDatabaseCount('tasks', 1);
$this->assertSame($events, DB::table('audit_events')->where('entity_type', 'tasks')->count());
$this->assertSame($before, $this->lead->fresh()->getAttributes());
}
public function test_a_changed_access_revision_rejects_the_old_session_without_overwriting_preparation(): void
{
$this->postJson($this->url(), $this->input())->assertOk();
$stored = DB::table('crm_task_preparations')->value('snapshot');
DB::table('users')->where('id', $this->manager->id)->increment('portal_access_revision');
$input = $this->input();
$input['version'] = 1;
$this->postJson($this->url(), $input)->assertUnauthorized();
$this->assertSame($stored, DB::table('crm_task_preparations')->value('snapshot'));
$this->assertDatabaseCount('tasks', 0);
}
public function test_accepted_integer_strings_and_uppercase_attempt_keys_have_a_canonical_json_projection(): void
{
$input = $this->input();
$key = (string) Str::uuid();
$input['snapshot']['generation']['title'] = '2';
$input['snapshot']['pending'] = ['values' => ['title' => 'SYNTHETIC pending', 'due_at' => now()->addYear()->toIso8601String(), 'assignee_user_id' => (string) $this->manager->id, 'client_key' => strtoupper($key)],
'generation' => ['title' => '1', 'due_at' => 0, 'assignee_user_id' => 0]];
$this->postJson($this->url(), $input)->assertOk()->assertJsonPath('preparation.snapshot.generation.title', 2)
->assertJsonPath('preparation.snapshot.pending.generation.title', 1)->assertJsonPath('preparation.snapshot.pending.values.client_key', $key);
$this->postJson($this->url(), $input)->assertOk()->assertJsonPath('preparation.version', 1);
$this->assertSame(1, DB::table('audit_events')->where('source_system', 'portal-crm-task-preparation')->count());
$this->assertDatabaseCount('tasks', 0);
}
}