Files
Investidor-V2/tests/Feature/SupportIncidentNoticesTest.php
T

174 lines
12 KiB
PHP

<?php
namespace Tests\Feature;
use App\Models\Opportunity;
use App\Models\User;
use App\Services\OpportunityTechnicalIncidents;
use App\Services\OpportunityTechnicalSupport;
use App\Services\PortalProfileAccess;
use App\Services\PortalProfileAdministration;
use App\Services\TechnicalIncidentHandling;
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 Inertia\Testing\AssertableInertia as Assert;
use Tests\TestCase;
final class SupportIncidentNoticesTest extends TestCase
{
use RefreshDatabase;
private User $support;
private User $colleague;
private User $reporter;
private Opportunity $opportunity;
private string $reference;
private string $organization;
protected function setUp(): void
{
parent::setUp();
$this->withoutVite();
Http::fake();
Mail::fake();
config(['portal.profile_access_enabled' => true, 'suitecrm.enabled' => false, 'investmoz_ai.enabled' => false]);
$approver = User::factory()->create();
$this->support = User::factory()->create(['name' => 'SYNTHETIC first support']);
$this->colleague = User::factory()->create(['name' => 'SYNTHETIC second support']);
$this->reporter = User::factory()->create();
$this->organization = (string) Str::uuid();
DB::table('organizations')->insert(['id' => $this->organization, 'name' => 'SYNTHETIC incident organization', 'organization_type' => 'private']);
foreach ([$this->support, $this->colleague, $this->reporter] as $actor) {
app(PortalProfileAdministration::class)->insertGrant((string) Str::uuid(), $approver, ['user_id' => $actor->id, 'role' => $actor === $this->reporter ? 'promoter' : 'technical_support', 'organization_id' => $this->organization, 'opportunity_types' => ['investment_project'], 'source' => 'synthetic-support-treatment-fixture']);
}
DB::table('memberships')->insert(['id' => (string) Str::uuid(), 'user_id' => $this->reporter->id, 'organization_id' => $this->organization, 'role' => 'officer']);
$id = (string) Str::uuid();
$this->opportunity = new Opportunity(['title' => 'PRIVATE SYNTHETIC INVESTMENT', 'reference' => $id, 'slug' => $id, 'status' => 'draft']);
$this->opportunity->forceFill(['owner_user_id' => $this->reporter->id, 'organization_id' => $this->organization, 'opportunity_category' => 'investment_project', 'management_version' => 4, 'workflow_context' => ['working_state' => 'draft', 'content_revision' => 3]])->save();
// Synthetic root metadata fixture. Real failing HTTP submission/publication is covered by OpportunityTechnicalIncidentsTest.
$this->reference = app(OpportunityTechnicalIncidents::class)->record($this->reporter, $this->opportunity, 'submit', str_repeat('a', 64), new \RuntimeException('PRIVATE SYNTHETIC FAILURE'))['reference'];
$this->actingAs($this->support);
}
private function url(): string
{
return '/suporte-tecnico/incidentes/'.$this->reference.'/consultado';
}
private function notices(): int
{
return DB::table('audit_events')->where('source_system', 'portal-opportunity-support-notices')->count();
}
public function test_own_consultation_is_private_idempotent_and_changes_neither_treatment_nor_business(): void
{
$before = $this->opportunity->fresh()->getRawOriginal();
$root = DB::table('audit_events')->where('id', $this->reference)->first();
$grantCount = DB::table('portal_role_assignments')->count();
$this->get('/suporte-tecnico/incidentes')->assertOk()->assertHeader('Cache-Control', 'no-store, private')->assertInertia(fn (Assert $page) => $page->where('profileAccess.technicalSupportUnread', 1)->where('incidents.data.0.unread', true));
$this->get('/suporte-tecnico/incidentes/'.$this->reference)->assertOk()->assertInertia(fn (Assert $page) => $page->where('notice.unread', true));
$this->assertSame(0, $this->notices());
$first = $this->postJson($this->url(), ['actor_id' => $this->colleague->id])->assertOk()->assertHeader('Cache-Control', 'no-store, private')->assertJsonPath('actor_id', $this->support->id)->assertJsonPath('read', true)->json();
$this->postJson($this->url())->assertOk()->assertExactJson($first);
$this->assertSame(1, $this->notices());
$this->get('/suporte-tecnico/incidentes?unread=1')->assertInertia(fn (Assert $page) => $page->where('incidents.total', 0)->where('profileAccess.technicalSupportUnread', 0));
$this->get('/suporte-tecnico/incidentes/'.$this->reference)->assertInertia(fn (Assert $page) => $page->where('notice.unread', false)->where('incident.handling.version', 0)->has('history.data', 0)->has('commands', 0));
$this->actingAs($this->colleague)->get('/suporte-tecnico/incidentes?unread=1')->assertInertia(fn (Assert $page) => $page->where('incidents.total', 1)->where('incidents.data.0.unread', true)->where('profileAccess.technicalSupportUnread', 1));
$this->assertEquals($root, DB::table('audit_events')->where('id', $this->reference)->first());
$this->assertSame($before, $this->opportunity->fresh()->getRawOriginal());
$this->assertSame($grantCount, DB::table('portal_role_assignments')->count());
$this->assertSame(0, DB::table('tasks')->count());
}
public function test_unread_filter_runs_before_pagination_and_preserves_status_and_return_context(): void
{
$support = app(OpportunityTechnicalSupport::class);
for ($i = 0; $i < 24; $i++) {
$reference = app(OpportunityTechnicalIncidents::class)->record($this->reporter, $this->opportunity, 'submit', str_repeat('a', 64), new \RuntimeException('SYNTHETIC recent root'))['reference'];
$support->markConsulted($this->support, $reference);
}
app(TechnicalIncidentHandling::class)->decide($this->support, $this->reference, ['version' => 0, 'action' => 'resolve', 'note' => 'SYNTHETIC explicit technical resolution', 'operation_key' => (string) Str::uuid()]);
$this->get('/suporte-tecnico/incidentes?unread=1&status=resolved')->assertOk()->assertInertia(fn (Assert $page) => $page->where('incidents.total', 1)->where('incidents.data.0.reference', $this->reference)->where('incidents.data.0.handling.status', 'resolved')->where('filters.unread', true));
$this->get('/suporte-tecnico/incidentes/'.$this->reference.'?unread=1&status=resolved&page=2')->assertInertia(fn (Assert $page) => $page->where('backUrl', '/suporte-tecnico/incidentes?page=2&status=resolved&unread=1')->where('notice.unread', true));
$this->postJson($this->url())->assertOk();
$this->assertSame(0, $support->unreadCount($this->support));
$this->get('/suporte-tecnico/incidentes?unread=1&status=resolved')->assertInertia(fn (Assert $page) => $page->where('incidents.total', 0));
$this->getJson('/suporte-tecnico/incidentes?unread=invalid')->assertUnprocessable();
}
public function test_current_scope_revocation_expiry_and_auditor_precedence_apply_to_notices(): void
{
$support = app(OpportunityTechnicalSupport::class);
$this->opportunity->forceFill(['opportunity_category' => 'real_estate'])->save();
$this->assertSame(0, $support->unreadCount($this->support));
$this->postJson($this->url())->assertNotFound();
$this->opportunity->forceFill(['opportunity_category' => 'investment_project', 'organization_id' => null])->save();
$this->assertSame(0, $support->unreadCount($this->support));
$this->postJson($this->url())->assertNotFound();
$this->opportunity->forceFill(['organization_id' => $this->organization])->save();
DB::table('portal_role_assignments')->where('user_id', $this->support->id)->update(['starts_at' => now()->subDay(), 'expires_at' => now()->subMinute()]);
$this->postJson($this->url())->assertForbidden();
DB::table('portal_role_assignments')->where('user_id', $this->support->id)->update(['expires_at' => null, 'revoked_at' => now()]);
$this->postJson($this->url())->assertForbidden();
DB::table('portal_role_assignments')->where('user_id', $this->support->id)->update(['revoked_at' => null]);
app(PortalProfileAdministration::class)->insertGrant((string) Str::uuid(), $this->reporter, ['user_id' => $this->support->id, 'role' => 'auditor', 'opportunity_types' => ['investment_project']]);
$this->postJson($this->url())->assertForbidden();
$this->assertNull(app(PortalProfileAccess::class)->navigation($this->support)['technicalSupport']);
$this->assertNull(app(PortalProfileAccess::class)->navigation($this->support)['technicalSupportUnread']);
$this->assertSame(0, $this->notices());
}
public function test_overlapping_grants_count_each_root_once_and_legacy_management_is_not_support(): void
{
app(PortalProfileAdministration::class)->insertGrant((string) Str::uuid(), $this->reporter, ['user_id' => $this->support->id, 'role' => 'technical_support', 'opportunity_types' => ['investment_project']]);
$this->assertSame(1, app(OpportunityTechnicalSupport::class)->unreadCount($this->support));
config(['portal.managers' => [$this->reporter->email]]);
$this->actingAs($this->reporter)->postJson($this->url())->assertForbidden();
$this->assertNull(app(PortalProfileAccess::class)->navigation($this->reporter)['technicalSupportUnread']);
$this->actingAs($this->support);
$this->support->forceFill(['email_verified_at' => null])->save();
$this->postJson($this->url())->assertForbidden();
$this->assertSame(0, $this->notices());
}
public function test_failed_acknowledgement_rolls_back_and_explicit_retry_can_confirm_once(): void
{
DB::statement("ALTER TABLE audit_events ADD CONSTRAINT qa_reject_notice CHECK (source_system <> 'portal-opportunity-support-notices')");
$this->postJson($this->url())->assertStatus(503);
$this->assertSame(0, $this->notices());
$this->assertSame(1, app(OpportunityTechnicalSupport::class)->unreadCount($this->support));
DB::statement('ALTER TABLE audit_events DROP CONSTRAINT qa_reject_notice');
$this->postJson($this->url())->assertOk();
$this->postJson($this->url())->assertOk();
$this->assertSame(1, $this->notices());
}
public function test_optional_count_failure_does_not_poison_the_callers_transaction(): void
{
DB::statement('ALTER TABLE audit_events RENAME COLUMN actor_user_id TO qa_missing_notice_actor');
$this->assertNull(app(OpportunityTechnicalSupport::class)->unreadCount($this->support));
$this->assertGreaterThan(0, DB::table('users')->count());
DB::statement('ALTER TABLE audit_events RENAME COLUMN qa_missing_notice_actor TO actor_user_id');
$this->assertSame(1, app(OpportunityTechnicalSupport::class)->unreadCount($this->support));
}
public function test_invalid_treatment_does_not_prevent_consultation_or_expose_other_actors_read_state(): void
{
DB::table('audit_events')->insert(['id' => (string) Str::uuid(), 'source_system' => 'portal-opportunity-support', 'entity_type' => 'opportunity-technical-incident', 'entity_id' => $this->reference, 'action' => 'incident_handled', 'after_data' => json_encode(['handling' => ['version' => 1, 'status' => 'invalid']], JSON_THROW_ON_ERROR)]);
$this->get('/suporte-tecnico/incidentes?unread=1')->assertOk()->assertInertia(fn (Assert $page) => $page->where('incidents.data.0.handling', null)->where('incidents.data.0.unread', true));
$this->postJson($this->url())->assertOk();
$recent = app(OpportunityTechnicalIncidents::class)->recent($this->reporter, $this->opportunity)[0];
$this->assertArrayNotHasKey('unread', $recent);
$this->assertArrayNotHasKey('read_at', $recent);
$this->assertSame(1, app(OpportunityTechnicalSupport::class)->unreadCount($this->colleague));
}
}