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 */ 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); } }