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)); } }