withoutVite(); Http::fake(); Mail::fake(); $this->travelTo(now()->setDate(2026, 11, 3)->setTime(10, 0)); $this->manager = User::factory()->create(); $this->other = User::factory()->create(); config(['portal.managers' => [$this->manager->email], 'portal.profile_access_enabled' => true, 'investor-path.enabled' => false, 'suitecrm.enabled' => false, 'investmoz_ai.enabled' => false]); } private function interest(User $actor, array $changes = []): InterestRequest { return InterestRequest::create(['reference' => 'SYNTHETIC-'.Str::uuid(), 'full_name' => 'SYNTHETIC private contact', 'email' => 'synthetic-private@example.invalid', 'investor_type' => 'empresa', 'message' => 'SYNTHETIC dashboard queue, not an active investor request.', 'country' => 'MZ', 'sector_interest' => 'Agricultura', 'privacy_consent' => true, 'assigned_to' => $actor->id, 'status' => 'received', 'next_action' => 'SYNTHETIC assigned follow-up', 'next_action_due_at' => now()->subHour(), ...$changes]); } /** @return array{id:string,case_id:string} */ private function deskRequest(User $analyst, array $changes = [], bool $closed = false): array { $desk = app(VirtualDesk::class); $this->edition ??= app(DeskServiceEditor::class)->publish($this->manager, 'project-change', ['base_version' => 0, 'definition' => config('virtual-desk.services.project-change'), 'reason' => 'SYNTHETIC dashboard queue fixture', 'confirm' => true])->id; $case = $desk->createCase($this->other, ['title' => 'SYNTHETIC assigned dossier', 'client_key' => (string) Str::uuid(), 'reuse_consent' => true]); $request = $desk->createRequest($this->other, $case->id, $this->edition, (string) Str::uuid()); if (($changes['status'] ?? 'reviewing') !== 'draft') { $desk->save($this->other, $request->id, ['version' => 1, 'payload' => ['title' => 'SYNTHETIC queue project', 'description' => 'SYNTHETIC private preparation', 'original_reference' => 'SYNTHETIC-REF', 'change_reason' => 'SYNTHETIC internal change'], 'consent' => true], true); } // Queue fixture only: owner close/reopen and submission are exercised by their own accepted HTTP suites. DB::table('desk_requests')->where('id', $request->id)->update(['analyst_id' => $analyst->id, 'status' => 'reviewing', 'due_at' => now()->subHour(), ...$changes]); if ($closed) { DB::table('desk_cases')->where('id', $case->id)->update(['closed_at' => now()]); } return ['id' => $request->id, 'case_id' => $case->id]; } private function task(User $actor, array $changes = []): CrmTask { $lead = Lead::create(['title' => 'SYNTHETIC assigned lead', 'owner_user_id' => $actor->id, 'status' => 'new']); return CrmTask::create(['lead_id' => $lead->id, 'title' => 'SYNTHETIC assigned CRM task', 'assignee_user_id' => $actor->id, 'status' => 'open', 'due_at' => now()->subHour(), ...$changes]); } private function queue(string $url = '/dashboard'): ?array { return $this->get($url)->assertOk()->viewData('page')['props']['workQueue']; } public function test_public_investor_guest_and_unverified_actor_do_not_gain_operational_data(): void { $this->interest($this->manager); $this->task($this->manager); $this->actingAs($this->other)->get('/dashboard')->assertInertia(fn ($page) => $page->where('workQueue', null)->where('summary', null)); $this->get('/management/crm/leads')->assertForbidden(); $unverified = User::factory()->unverified()->create(); $this->actingAs($unverified)->get('/dashboard')->assertRedirect('/email/verify'); auth()->forgetGuards(); $this->flushSession(); $this->get('/dashboard')->assertRedirect('/login'); } public function test_assigned_interest_operator_sees_only_active_personal_records_and_real_destination(): void { $operator = User::factory()->create(); $first = $this->interest($operator); $this->interest($operator, ['next_action_due_at' => now()->addDay()]); $this->interest($operator, ['next_action_due_at' => null, 'next_action' => null]); $other = $this->interest($this->other); foreach (['closed', 'cancelled', 'refused', 'converted'] as $status) { $this->interest($operator, ['status' => $status]); } $this->interest($operator)->delete(); $this->actingAsWithMfa($operator); $queue = $this->queue(); $this->assertSame(['interests'], array_keys($queue['groups'])); $group = $queue['groups']['interests']; $this->assertSame(3, $group['total']); $this->assertSame(1, $group['overdue']); $this->assertSame((string) $first->id, $group['items'][0]['id']); $this->assertNull($group['items'][2]['due_at']); $this->assertSame('Acompanhar manifestação', $group['items'][2]['title']); $this->get($group['items'][0]['href'])->assertOk(); $this->get('/management/interests/'.$other->id)->assertForbidden(); $this->assertStringNotContainsString('synthetic-private@example.invalid', json_encode($queue)); } public function test_personal_groups_are_separate_and_dashboard_does_not_mutate_workflow_or_audit(): void { $interest = $this->interest($this->manager); $request = $this->deskRequest($this->manager); $task = $this->task($this->manager); $this->interest($this->other); $this->deskRequest($this->other); $this->task($this->other); $this->actingAsWithMfa($this->manager); $before = [DB::table('audit_events')->count(), DB::table('desk_events')->count(), DB::table('desk_notifications')->count(), $interest->fresh()->getAttributes(), $task->fresh()->getAttributes(), (array) DB::table('desk_requests')->find($request['id'])]; $queue = $this->queue(); foreach (['interests', 'desk', 'crm_tasks'] as $kind) { $this->assertSame(1, $queue['groups'][$kind]['total']); $this->assertSame(1, $queue['groups'][$kind]['overdue']); } $after = [DB::table('audit_events')->count(), DB::table('desk_events')->count(), DB::table('desk_notifications')->count(), $interest->fresh()->getAttributes(), $task->fresh()->getAttributes(), (array) DB::table('desk_requests')->find($request['id'])]; $this->assertSame($before, $after); $this->getJson($queue['groups']['desk']['items'][0]['href'])->assertOk()->assertJsonPath('request.id', $request['id']); $href = $queue['groups']['crm_tasks']['items'][0]['href']; $this->assertStringEndsWith('#crm-task-'.$task->id, $href); $this->get(strtok($href, '#'))->assertOk()->assertInertia(fn ($page) => $page->where('lead.id', $task->lead_id)); } public function test_closed_dossiers_and_finished_deleted_or_unassigned_records_leave_the_active_queue(): void { $closed = $this->deskRequest($this->manager); $this->task($this->manager, ['desk_case_id' => $closed['case_id'], 'accepted_by' => $this->other->id, 'accepted_at' => now(), 'guidance_key' => 'SYNTHETIC-dashboard', 'data_classification' => 'confidential', 'external_sync_status' => 'pending_authorization']); DB::table('desk_cases')->where('id', $closed['case_id'])->update(['closed_at' => now()]); foreach (['draft', 'closed', 'cancelled'] as $status) { $this->deskRequest($this->manager, ['status' => $status]); } $this->deskRequest($this->manager, ['analyst_id' => null]); foreach (['done', 'cancelled'] as $status) { $this->task($this->manager, ['status' => $status]); } $this->task($this->manager)->delete(); $deletedLead = $this->task($this->manager); Lead::findOrFail($deletedLead->lead_id)->delete(); $active = $this->task($this->manager, ['due_at' => null]); $this->actingAsWithMfa($this->manager); $queue = $this->queue(); $this->assertSame(0, $queue['groups']['desk']['total']); $this->assertSame(1, $queue['groups']['crm_tasks']['total']); $this->assertSame(0, $queue['groups']['crm_tasks']['overdue']); $this->assertSame($active->id, $queue['groups']['crm_tasks']['items'][0]['id']); $this->assertDatabaseHas('desk_requests', ['id' => $closed['id'], 'status' => 'reviewing']); } public function test_assigned_desk_consultation_does_not_require_unrelated_management_privileges_and_revocation_is_immediate(): void { $analyst = User::factory()->create(); $request = $this->deskRequest($analyst); $this->interest($analyst); // No confirmed MFA: detail authority for interests is still required. $this->actingAs($analyst); $queue = $this->queue(); $this->assertSame(['desk'], array_keys($queue['groups'])); $this->getJson($queue['groups']['desk']['items'][0]['href'])->assertOk(); DB::table('desk_requests')->where('id', $request['id'])->update(['analyst_id' => $this->manager->id]); $this->assertNull($this->queue()); $this->getJson('/balcao-virtual/pedidos/'.$request['id'])->assertForbidden(); } public function test_independent_pagination_covers_every_row_once_and_uses_full_group_totals(): void { for ($i = 0; $i < 13; $i++) { $this->interest($this->manager); } for ($i = 0; $i < 6; $i++) { $this->deskRequest($this->manager); $this->task($this->manager); } $this->actingAsWithMfa($this->manager); $queue = $this->queue('/dashboard?desk_page=2&crm_tasks_page=2'); $ids = []; for ($page = 1; $page <= 3; $page++) { $group = $queue['groups']['interests']; $this->assertSame($page, $group['page']); $this->assertSame(13, $group['total']); $this->assertSame(13, $group['overdue']); $ids = [...$ids, ...array_column($group['items'], 'id')]; $this->assertSame(2, $queue['groups']['desk']['page']); $this->assertSame(2, $queue['groups']['crm_tasks']['page']); if ($group['next']) { $queue = $this->queue($group['next']); } } $this->assertCount(13, array_unique($ids)); $this->assertNull($group['next']); $this->assertSame($ids, array_map('strval', InterestRequest::orderBy('id')->pluck('id')->all())); $this->assertSame(1, count($queue['groups']['desk']['items'])); } public function test_assignment_changes_recover_stale_pages_and_deadline_equal_to_now_is_not_overdue(): void { $this->interest($this->manager, ['next_action_due_at' => now()]); $this->actingAsWithMfa($this->manager); $queue = $this->queue('/dashboard?interests_page=99'); $this->assertSame(1, $queue['groups']['interests']['page']); $this->assertSame(0, $queue['groups']['interests']['overdue']); $this->assertFalse($queue['groups']['interests']['items'][0]['overdue']); InterestRequest::query()->update(['assigned_to' => $this->other->id]); $queue = $this->queue('/dashboard?interests_page=99'); $this->assertSame(0, $queue['groups']['interests']['total']); $this->assertSame(1, $queue['groups']['interests']['page']); } public function test_mfa_expiration_auditor_restriction_and_removed_manager_are_not_bypassed(): void { $operator = User::factory()->create(); $this->interest($operator); $this->actingAsWithMfa($operator); $this->assertNotNull($this->queue()); $this->withSession(['portal.mfa_at' => time() - 901]); $this->assertNull($this->queue()); $this->interest($this->manager); $this->task($this->manager); $this->actingAsWithMfa($this->manager); app(PortalProfileAdministration::class)->insertGrant((string) Str::uuid(), $this->other, ['user_id' => $this->manager->id, 'role' => 'auditor', 'opportunity_types' => [], 'responsibilities' => []]); $this->get('/dashboard')->assertInertia(fn ($page) => $page->where('workQueue', null)->where('summary', null)); $this->get('/management/crm/leads')->assertForbidden(); config(['portal.managers' => []]); $this->get('/dashboard')->assertInertia(fn ($page) => $page->where('summary', null)->where('workQueue', null)); } public function test_unlinked_crm_still_works_when_the_optional_desk_is_unavailable(): void { $this->task($this->manager); $this->actingAs($this->manager); DB::statement('ALTER TABLE desk_cases RENAME TO desk_cases_qa_unavailable'); try { $queue = $this->queue(); $this->assertArrayNotHasKey('desk', $queue['groups']); $this->assertSame(1, $queue['groups']['crm_tasks']['total']); $this->get(strtok($queue['groups']['crm_tasks']['items'][0]['href'], '#'))->assertOk(); } finally { DB::statement('ALTER TABLE desk_cases_qa_unavailable RENAME TO desk_cases'); } } public function test_removing_manager_configuration_removes_crm_queue_and_dashboard_summary_independently_of_auditor_status(): void { $this->task($this->manager); $this->actingAs($this->manager); $this->assertSame(1, $this->queue()['groups']['crm_tasks']['total']); config(['portal.managers' => []]); $this->get('/dashboard')->assertInertia(fn ($page) => $page->where('summary', null)->where('workQueue', null)); $this->get('/management/crm/leads')->assertForbidden(); Http::assertNothingSent(); Mail::assertNothingSent(); } public function test_invalid_pagination_is_rejected_without_querying_an_arbitrary_scope(): void { $this->actingAs($this->manager)->getJson('/dashboard?desk_page=-1')->assertUnprocessable()->assertJsonValidationErrors('desk_page'); $this->getJson('/dashboard?crm_tasks_page=other')->assertUnprocessable()->assertJsonValidationErrors('crm_tasks_page'); } }