withoutVite(); Http::fake(); Mail::fake(); $this->manager = User::factory()->create(); $this->owner = User::factory()->create(); config(['portal.managers' => [$this->manager->email], 'suitecrm.enabled' => false, 'investmoz_ai.enabled' => false]); $this->lead = Lead::create(['title' => 'SYNTHETIC next action lead', 'status' => 'new', 'owner_user_id' => $this->manager->id])->fresh(); $this->actingAsWithMfa($this->manager); } private function caseId(): string { return app(VirtualDesk::class)->createCase($this->owner, ['title' => 'SYNTHETIC next action dossier', 'client_key' => (string) Str::uuid(), 'reuse_consent' => true])->id; } private function task(?string $case = null, array $changes = []): CrmTask { $data = ['lead_id' => $this->lead->id, 'title' => 'SYNTHETIC next action task', 'status' => 'open', 'due_at' => now()->addDay(), 'assignee_user_id' => $this->manager->id]; if ($case !== null) { $data = [...$data, 'desk_case_id' => $case, 'accepted_by' => $this->owner->id, 'accepted_at' => now(), 'guidance_key' => 'synthetic-'.Str::uuid(), 'data_classification' => 'confidential', 'external_sync_status' => 'pending_authorization']; } return CrmTask::create([...$data, ...$changes])->fresh(); } private function state(string $case, string $state): void { app(VirtualDesk::class)->changeCaseState($this->owner, $case, ['version' => (int) DB::table('desk_cases')->where('id', $case)->value('version'), 'state' => $state, 'reason' => 'SYNTHETIC owner changes dossier availability', 'client_key' => (string) Str::uuid()]); } private function qualification(string $status = 'qualified'): array { return ['version' => $this->lead->fresh()->version_no, 'owner_user_id' => $this->manager->id, 'status' => $status, 'qualification_reason' => 'SYNTHETIC internal qualification reason']; } public function test_a_suspended_future_task_cannot_qualify_the_lead(): void { $case = $this->caseId(); $task = $this->task($case); $this->state($case, 'closed'); $before = [$this->lead->fresh()->getAttributes(), $task->fresh()->getAttributes(), DB::table('audit_events')->count()]; $this->patchJson('/management/crm/leads/'.$this->lead->id, $this->qualification())->assertUnprocessable()->assertJsonValidationErrors('status'); $this->assertSame($before, [$this->lead->fresh()->getAttributes(), $task->fresh()->getAttributes(), DB::table('audit_events')->count()]); } public function test_a_suspended_alternative_cannot_allow_finishing_the_last_available_task(): void { $current = $this->task(); $case = $this->caseId(); $alternative = $this->task($case); $this->state($case, 'closed'); $this->patchJson('/management/crm/leads/'.$this->lead->id.'/tasks/'.$current->id, ['version' => 1, 'status' => 'done'])->assertUnprocessable()->assertJsonValidationErrors('status'); $this->assertSame('open', $current->fresh()->status); $this->assertSame(1, $current->fresh()->version_no); $this->assertSame('open', $alternative->fresh()->status); } public function test_guidance_cannot_bypass_the_next_action_rule_for_a_task_linked_to_an_active_lead(): void { $case = $this->caseId(); $task = $this->task($case); $events = DB::table('desk_events')->count(); $this->postJson('/balcao-virtual/dossiers/'.$case.'/tarefas/'.$task->id, ['version' => 1, 'status' => 'done', 'reason' => 'SYNTHETIC attempted final response'])->assertUnprocessable()->assertJsonValidationErrors('status'); $this->assertSame('open', $task->fresh()->status); $this->assertSame(1, $task->fresh()->version_no); $this->assertSame($events, DB::table('desk_events')->count()); } public function test_owner_reopening_restores_eligibility_without_rewriting_task_states(): void { $current = $this->task(); $case = $this->caseId(); $alternative = $this->task($case); $before = $alternative->getAttributes(); $this->state($case, 'closed'); $this->state($case, 'open'); $this->assertSame($before, $alternative->fresh()->getAttributes()); $this->patchJson('/management/crm/leads/'.$this->lead->id, $this->qualification())->assertRedirect(); $this->patchJson('/management/crm/leads/'.$this->lead->id.'/tasks/'.$current->id, ['version' => 1, 'status' => 'done'])->assertRedirect(); $this->postJson('/balcao-virtual/dossiers/'.$case.'/tarefas/'.$alternative->id, ['version' => 1, 'status' => 'cancelled', 'reason' => 'SYNTHETIC last alternative cannot finish'])->assertUnprocessable()->assertJsonValidationErrors('status'); $this->assertSame('open', $alternative->fresh()->status); } public function test_guidance_can_finish_with_another_available_future_task_and_keeps_its_audit_route(): void { $case = $this->caseId(); $current = $this->task($case); $other = $this->task(null, ['status' => 'in_progress']); $this->postJson('/balcao-virtual/dossiers/'.$case.'/tarefas/'.$current->id, ['version' => 1, 'status' => 'done', 'reason' => 'SYNTHETIC response with next action'])->assertOk(); $this->assertSame('done', $current->fresh()->status); $this->assertSame(2, $current->fresh()->version_no); $this->assertSame('in_progress', $other->fresh()->status); $this->assertSame(1, DB::table('desk_events')->where('action', 'guidance_task_updated')->count()); $this->postJson('/balcao-virtual/dossiers/'.$case.'/tarefas/'.$current->id, ['version' => 1, 'status' => 'open', 'reason' => 'SYNTHETIC stale response'])->assertConflict(); } public function test_expired_undated_deleted_and_other_lead_tasks_are_not_future_replacements_but_disqualification_still_works(): void { $current = $this->task(null, ['due_at' => now()->subDay()]); $this->task(null, ['due_at' => null]); $this->task()->delete(); $other = Lead::create(['title' => 'SYNTHETIC different lead', 'status' => 'new']); $this->task(null, ['lead_id' => $other->id]); $this->patchJson('/management/crm/leads/'.$this->lead->id, $this->qualification())->assertUnprocessable()->assertJsonValidationErrors('status'); $this->patchJson('/management/crm/leads/'.$this->lead->id, $this->qualification('disqualified'))->assertRedirect(); $this->patchJson('/management/crm/leads/'.$this->lead->id.'/tasks/'.$current->id, ['version' => 1, 'status' => 'done'])->assertRedirect(); } public function test_guidance_without_a_crm_lead_keeps_existing_permissions_and_owner_closure(): void { $case = $this->caseId(); $task = $this->task($case, ['lead_id' => null, 'assignee_user_id' => $this->owner->id]); $this->actingAs($this->owner)->postJson('/balcao-virtual/dossiers/'.$case.'/tarefas/'.$task->id, ['version' => 1, 'status' => 'done', 'reason' => 'SYNTHETIC standalone investor task'])->assertOk(); $this->state($case, 'closed'); $this->postJson('/balcao-virtual/dossiers/'.$case.'/tarefas/'.$task->id, ['version' => 2, 'status' => 'open', 'reason' => 'SYNTHETIC closed case remains read only'])->assertConflict(); $this->assertSame('done', $task->fresh()->status); } public function test_new_rule_does_not_grant_crm_or_dossier_access(): void { $case = $this->caseId(); $task = $this->task($case); $other = User::factory()->create(); $this->actingAs($other)->patchJson('/management/crm/leads/'.$this->lead->id.'/tasks/'.$task->id, ['version' => 1, 'status' => 'done'])->assertForbidden(); $this->postJson('/balcao-virtual/dossiers/'.$case.'/tarefas/'.$task->id, ['version' => 1, 'status' => 'done', 'reason' => 'SYNTHETIC unrelated account'])->assertForbidden(); $this->assertSame('open', $task->fresh()->status); } public function test_archived_lead_does_not_block_an_existing_authorized_guidance_response(): void { $case = $this->caseId(); $task = $this->task($case); $this->lead->delete(); $this->postJson('/balcao-virtual/dossiers/'.$case.'/tarefas/'.$task->id, ['version' => 1, 'status' => 'done', 'reason' => 'SYNTHETIC archived lead retains dossier response'])->assertOk(); $this->assertSame('done', $task->fresh()->status); $this->assertSoftDeleted('leads', ['id' => $this->lead->id]); } public function test_unlinked_crm_does_not_depend_on_optional_desk_tables(): void { $first = $this->task(); $this->task(); DB::statement('ALTER TABLE desk_cases RENAME TO desk_cases_qa_unavailable'); try { $this->patchJson('/management/crm/leads/'.$this->lead->id, $this->qualification())->assertRedirect(); $this->patchJson('/management/crm/leads/'.$this->lead->id.'/tasks/'.$first->id, ['version' => 1, 'status' => 'done'])->assertRedirect(); } finally { DB::statement('ALTER TABLE desk_cases_qa_unavailable RENAME TO desk_cases'); } } public function test_read_models_distinguish_suspended_tasks_and_keep_their_history(): void { $case = $this->caseId(); $suspended = $this->task($case, ['due_at' => now()->subDay()]); $this->state($case, 'closed'); $available = $this->task(null, ['due_at' => now()->subHour()]); $this->get('/management/crm/leads')->assertInertia(fn ($page) => $page->where('leads.data.0.overdue_tasks_count', 1)); $this->get('/management/crm/leads/'.$this->lead->id)->assertInertia(fn ($page) => $page->where('nextAction.future_tasks', 0)->has('lead.tasks', 2)); $this->assertSame('open', $suspended->fresh()->status); $this->assertSame('open', $available->fresh()->status); $available->delete(); $alerts = app(AssistiveRules::class)->quality(); $this->assertCount(1, array_filter($alerts, fn ($row) => ($row['rule'] ?? null) === 'missing_next_action' && ($row['record_id'] ?? null) === $this->lead->id)); Http::assertNothingSent(); Mail::assertNothingSent(); } }