withoutVite(); $this->first = User::factory()->create(); $this->second = User::factory()->create(); config(['portal.managers' => [$this->first->email, $this->second->email]]); $this->actingAsWithMfa($this->first); $this->investor = Investor::create(['legal_name' => 'SYNTHETIC original investor', 'investor_type' => 'domestic', 'status' => 'prospect'])->fresh(); } private function investorData(array $changes = []): array { return ['version' => 1, 'legal_name' => 'SYNTHETIC first edit', 'investor_type' => 'domestic', 'status' => 'active', ...$changes]; } public function test_stale_investor_form_cannot_overwrite_a_more_recent_edit_and_audit_excludes_personal_text(): void { $url = '/management/crm/investors/'.$this->investor->id; $this->patchJson($url, $this->investorData())->assertRedirect(); $this->actingAsWithMfa($this->second)->patchJson($url, $this->investorData(['legal_name' => 'SYNTHETIC stale edit']))->assertUnprocessable()->assertJsonValidationErrors('version'); $this->assertDatabaseHas('investors', ['id' => $this->investor->id, 'legal_name' => 'SYNTHETIC first edit', 'version_no' => 2]); $events = DB::table('audit_events')->where('entity_type', 'investors')->where('entity_id', $this->investor->id)->where('action', 'updated')->get(); $this->assertCount(1, $events); $this->assertSame(1, json_decode($events[0]->before_data, true)['values']['version_no']); $this->assertSame(2, json_decode($events[0]->after_data, true)['values']['version_no']); $this->assertStringNotContainsString('SYNTHETIC first edit', $events[0]->after_data); } public function test_shared_contact_has_one_version_across_investors_and_old_forms_do_not_restore_old_phone(): void { $contact = Contact::create(['full_name' => 'SYNTHETIC shared contact', 'email' => 'synthetic-shared@example.invalid', 'phone' => '111'])->fresh(); $other = Investor::create(['legal_name' => 'SYNTHETIC other investor', 'investor_type' => 'domestic', 'status' => 'prospect']); foreach ([$this->investor->id, $other->id] as $id) { DB::table('investor_contacts')->insert(['investor_id' => $id, 'contact_id' => $contact->id]); } $data = ['version' => 1, 'full_name' => 'SYNTHETIC shared contact', 'email' => 'SYNTHETIC-SHARED@example.invalid', 'phone' => '222']; $this->patchJson('/management/crm/investors/'.$this->investor->id.'/contacts/'.$contact->id, $data)->assertRedirect(); $this->actingAsWithMfa($this->second)->patchJson('/management/crm/investors/'.$other->id.'/contacts/'.$contact->id, [...$data, 'phone' => '111', 'full_name' => 'SYNTHETIC stale name'])->assertUnprocessable()->assertJsonValidationErrors('version'); $this->assertDatabaseHas('contacts', ['id' => $contact->id, 'phone' => '222', 'email' => 'synthetic-shared@example.invalid', 'version_no' => 2]); $this->get('/management/crm/investors/'.$other->id)->assertOk()->assertInertia(fn ($page) => $page->where('contacts.0.version_no', 2)->where('contacts.0.phone', '222')); } public function test_lead_and_task_edits_are_versioned_without_bypassing_the_next_action_rule(): void { $lead = Lead::create(['investor_id' => $this->investor->id, 'owner_user_id' => $this->first->id, 'title' => 'SYNTHETIC lead', 'status' => 'new'])->fresh(); $task = CrmTask::create(['lead_id' => $lead->id, 'title' => 'SYNTHETIC next action', 'status' => 'open', 'due_at' => now()->addDay(), 'assignee_user_id' => $this->first->id])->fresh(); $taskUrl = '/management/crm/leads/'.$lead->id.'/tasks/'.$task->id; $this->patchJson($taskUrl, ['version' => 1, 'status' => 'done'])->assertUnprocessable()->assertJsonValidationErrors('status'); $this->patchJson($taskUrl, ['version' => 1, 'status' => 'in_progress'])->assertRedirect(); CrmTask::create(['lead_id' => $lead->id, 'title' => 'SYNTHETIC alternative next action', 'status' => 'open', 'due_at' => now()->addDays(2), 'assignee_user_id' => $this->first->id]); $this->patchJson($taskUrl, ['version' => 1, 'status' => 'done'])->assertUnprocessable()->assertJsonValidationErrors('version'); $data = ['version' => 1, 'owner_user_id' => $this->first->id, 'status' => 'qualified', 'qualification_reason' => 'SYNTHETIC qualified after internal review']; $this->patchJson('/management/crm/leads/'.$lead->id, $data)->assertRedirect(); $this->actingAsWithMfa($this->second)->patchJson('/management/crm/leads/'.$lead->id, [...$data, 'status' => 'disqualified', 'qualification_reason' => 'SYNTHETIC stale qualification'])->assertUnprocessable()->assertJsonValidationErrors('version'); $this->assertDatabaseHas('leads', ['id' => $lead->id, 'status' => 'qualified', 'version_no' => 2]); $this->assertDatabaseHas('tasks', ['id' => $task->id, 'status' => 'in_progress', 'version_no' => 2]); } public function test_missing_version_is_an_explicit_validation_error_and_does_not_change_the_record(): void { $data = $this->investorData(); unset($data['version']); $this->patchJson('/management/crm/investors/'.$this->investor->id, $data)->assertUnprocessable()->assertJsonValidationErrors('version')->assertJsonPath('errors.version.0', 'Actualize os dados para recuperar a versão deste registo antes de gravar. As suas edições foram conservadas.'); $this->assertDatabaseHas('investors', ['id' => $this->investor->id, 'legal_name' => 'SYNTHETIC original investor', 'version_no' => 1]); } public function test_noop_does_not_create_another_revision_or_audit_and_metadata_cannot_be_reset_by_input(): void { $before = $this->investor->getAttributes(); $url = '/management/crm/investors/'.$this->investor->id; $this->patchJson($url, ['version' => 1, 'legal_name' => $this->investor->legal_name, 'investor_type' => 'domestic', 'status' => 'prospect'])->assertRedirect(); $this->assertSame($before, $this->investor->fresh()->getAttributes()); $this->assertSame(0, DB::table('audit_events')->where('entity_id', $this->investor->id)->count()); $this->patchJson($url, [...$this->investorData(), 'version_no' => 0])->assertRedirect(); $this->assertSame(2, $this->investor->fresh()->version_no); } public function test_other_existing_crm_writers_advance_the_revision_without_changing_their_authority(): void { PortalAudit::save($this->investor, ['status' => 'active'], $this->first->id); $this->assertSame(2, $this->investor->fresh()->version_no); $this->patchJson('/management/crm/investors/'.$this->investor->id, $this->investorData())->assertUnprocessable()->assertJsonValidationErrors('version'); } public function test_document_edition_is_not_incremented_by_the_crm_revision_policy(): void { $id = (string) Str::uuid(); DB::table('documents')->insert(['id' => $id, 'version_no' => 7, 'source_system' => 'synthetic', 'file_name' => 'synthetic-source.txt', 'media_type' => 'text/plain', 'storage_key' => 'synthetic-no-binary-required/'.$id, 'sha256' => str_repeat('a', 64), 'status' => 'quarantined']); $document = new class extends Model { protected $table = 'documents'; protected $guarded = []; public $incrementing = false; protected $keyType = 'string'; }; $document = $document->newQuery()->findOrFail($id); PortalAudit::save($document, ['file_name' => 'synthetic-renamed.txt'], $this->first->id); $this->assertDatabaseHas('documents', ['id' => $id, 'version_no' => 7, 'file_name' => 'synthetic-renamed.txt']); } public function test_a_valid_revision_does_not_grant_manager_authority(): void { $this->actingAs(User::factory()->create())->patchJson('/management/crm/investors/'.$this->investor->id, $this->investorData())->assertForbidden(); $this->assertDatabaseHas('investors', ['id' => $this->investor->id, 'version_no' => 1]); $this->actingAs($this->second)->patchJson('/management/crm/investors/'.$this->investor->id, $this->investorData())->assertRedirect(); $this->assertDatabaseHas('investors', ['id' => $this->investor->id, 'version_no' => 2, 'legal_name' => 'SYNTHETIC first edit']); } }