174 lines
12 KiB
PHP
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));
|
|
}
|
|
}
|