Files
Investidor-V2/tests/Feature/DashboardWorkQueueTest.php

276 lines
15 KiB
PHP

<?php
namespace Tests\Feature;
use App\Models\CrmTask;
use App\Models\InterestRequest;
use App\Models\Lead;
use App\Models\User;
use App\Services\DeskServiceEditor;
use App\Services\PortalProfileAdministration;
use App\Services\VirtualDesk;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Mail;
use Illuminate\Support\Str;
use Tests\Support\AuthenticatesQaActors;
use Tests\TestCase;
final class DashboardWorkQueueTest extends TestCase
{
use AuthenticatesQaActors;
use RefreshDatabase;
private User $manager;
private User $other;
private ?string $edition = null;
protected function setUp(): void
{
parent::setUp();
$this->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');
}
}