233 lines
13 KiB
PHP
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);
|
|
}
|
|
}
|