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 task creation 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.'/tasks'; } /** @return array{client_key:string,title:string,due_at:string,assignee_user_id:int} */ private function command(): array { return ['client_key' => (string) Str::uuid(), 'title' => 'SYNTHETIC next action', 'due_at' => now()->addDay()->toIso8601String(), 'assignee_user_id' => $this->manager->id]; } public function test_repeating_the_same_creation_does_not_create_a_second_task(): void { $command = $this->command(); $this->post($this->url(), $command)->assertRedirect(); $events = DB::table('audit_events')->where('entity_type', 'tasks')->count(); $this->post($this->url(), $command)->assertRedirect(); $this->assertDatabaseCount('tasks', 1); $this->assertSame($events, DB::table('audit_events')->where('entity_type', 'tasks')->count()); } public function test_retry_uses_the_original_receipt_after_deadline_expiry_and_assignee_retirement(): void { $other = User::factory()->create(); config(['portal.managers' => [$this->manager->email, $other->email]]); $command = [...$this->command(), 'assignee_user_id' => $other->id]; $this->post($this->url(), $command)->assertRedirect(); $events = DB::table('audit_events')->where('entity_type', 'tasks')->count(); config(['portal.managers' => [$this->manager->email]]); $this->travel(2)->days(); $this->post($this->url(), $command)->assertRedirect(); $this->assertDatabaseCount('tasks', 1); $this->assertSame($events, DB::table('audit_events')->where('entity_type', 'tasks')->count()); } public function test_a_used_key_cannot_create_a_modified_task(): void { $command = $this->command(); $this->post($this->url(), $command)->assertRedirect(); $events = DB::table('audit_events')->where('entity_type', 'tasks')->count(); $this->postJson($this->url(), [...$command, 'title' => 'SYNTHETIC changed'])->assertConflict(); $this->assertDatabaseCount('tasks', 1); $this->assertSame($events, DB::table('audit_events')->where('entity_type', 'tasks')->count()); } public function test_validation_does_not_consume_the_key_or_write_a_task(): void { $command = $this->command(); $this->postJson($this->url(), [...$command, 'due_at' => now()->subDay()->toIso8601String(), 'assignee_user_id' => 999999])->assertUnprocessable()->assertJsonValidationErrors(['due_at', 'assignee_user_id']); $this->assertDatabaseCount('tasks', 0); $this->assertDatabaseCount('audit_events', 0); $this->post($this->url(), $command)->assertRedirect(); $this->assertDatabaseCount('tasks', 1); } public function test_equivalent_offsets_and_whitespace_replay_the_same_canonical_values(): void { $command = $this->command(); $this->post($this->url(), $command)->assertRedirect(); $this->post($this->url(), [...$command, 'client_key' => strtoupper($command['client_key']), 'title' => ' '.$command['title'].' ', 'due_at' => CarbonImmutable::parse($command['due_at'])->setTimezone('Africa/Maputo')->toIso8601String()])->assertRedirect(); $this->assertDatabaseCount('tasks', 1); } public function test_receipts_are_scoped_to_the_current_actor_and_lead_without_raw_audit_payloads(): void { $command = $this->command(); $this->post($this->url(), $command)->assertRedirect(); $other = User::factory()->create(); config(['portal.managers' => [$this->manager->email, $other->email]]); $this->actingAsWithMfa($other); $this->post($this->url(), $command)->assertRedirect(); $secondLead = Lead::create(['title' => 'SYNTHETIC second', 'status' => 'new']); app(CrmTaskCreation::class)->create($other, $secondLead, $command, [$this->manager->id]); $this->assertDatabaseCount('tasks', 3); $this->get('/management/crm/leads/'.$this->lead->id)->assertOk()->assertInertia(fn (Assert $page) => $page->component('crm/lead') ->where('taskCreation.actor_id', $other->id)->where('taskCreation.lead_id', $this->lead->id) ->has('taskCreation.commands', 1)->where('taskCreation.commands.0.key', $command['client_key']) ->missing('taskCreation.commands.0.hash')->missing('taskCreation.commands.0.title')->missing('taskCreation.commands.0.after_data')); } public function test_replay_requires_current_management_authority_even_when_the_creation_was_accepted(): void { $command = $this->command(); $this->post($this->url(), $command)->assertRedirect(); $events = DB::table('audit_events')->where('entity_type', 'tasks')->count(); config(['portal.managers' => []]); $this->postJson($this->url(), $command)->assertForbidden(); $this->get('/management/crm/leads/'.$this->lead->id)->assertForbidden(); $this->assertDatabaseCount('tasks', 1); $this->assertSame($events, DB::table('audit_events')->where('entity_type', 'tasks')->count()); } public function test_completed_and_deleted_tasks_are_not_restored_by_replay(): void { $command = $this->command(); $this->post($this->url(), $command)->assertRedirect(); $task = CrmTask::firstOrFail(); $task->update(['status' => 'done', 'version_no' => 2]); $this->post($this->url(), $command)->assertRedirect(); $this->assertSame('done', $task->fresh()->status); $task->delete(); $events = DB::table('audit_events')->where('entity_type', 'tasks')->count(); $this->post($this->url(), $command)->assertRedirect(); $this->assertDatabaseCount('tasks', 1); $this->assertSoftDeleted($task); $this->assertSame($events, DB::table('audit_events')->where('entity_type', 'tasks')->count()); $this->get('/management/crm/leads/'.$this->lead->id)->assertInertia(fn (Assert $page) => $page->has('lead.tasks', 0)->has('taskCreation.commands', 1)); } public function test_legacy_callers_without_a_key_keep_the_existing_creation_contract(): void { $command = $this->command(); unset($command['client_key']); $this->post($this->url(), $command)->assertRedirect(); $this->post($this->url(), $command)->assertRedirect(); $this->assertDatabaseCount('tasks', 2); $this->assertSame(0, DB::table('audit_events')->where('source_system', 'portal-crm-task-creation')->count()); } public function test_malformed_keys_and_unapproved_assignees_are_rejected_without_writes(): void { $command = $this->command(); $this->postJson($this->url(), [...$command, 'client_key' => 'invalid'])->assertUnprocessable()->assertJsonValidationErrors('client_key'); $unapproved = User::factory()->create(); $this->postJson($this->url(), [...$command, 'assignee_user_id' => $unapproved->id])->assertUnprocessable()->assertJsonValidationErrors('assignee_user_id'); $this->assertDatabaseCount('tasks', 0); $this->assertDatabaseCount('audit_events', 0); } }