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

201 lines
15 KiB
PHP

<?php
namespace Tests\Feature;
use App\Models\Opportunity;
use App\Models\User;
use App\Services\OpportunityTechnicalIncidents;
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 TechnicalIncidentHandlingTest 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);
}
/** @return array{version:int,action:string,note:string,operation_key:string} */
private function input(int $version, string $action = 'note'): array
{
return ['version' => $version, 'action' => $action, 'note' => 'PRIVATE SYNTHETIC treatment note with evidence.', 'operation_key' => (string) Str::uuid()];
}
private function url(): string
{
return '/suporte-tecnico/incidentes/'.$this->reference;
}
public function test_team_treatment_preserves_business_data_and_encrypts_notes_with_a_private_history(): void
{
$before = $this->opportunity->fresh()->getRawOriginal();
$grantCount = DB::table('portal_role_assignments')->count();
$this->postJson($this->url(), $this->input(0, 'claim'))->assertOk()->assertJsonPath('handling.status', 'in_progress')->assertJsonPath('handling.assignee_user_id', $this->support->id);
$this->actingAs($this->colleague)->postJson($this->url(), [...$this->input(1, 'claim'), 'assignee_user_id' => $this->reporter->id])->assertOk()->assertJsonPath('handling.assignee_user_id', $this->colleague->id);
$this->postJson($this->url(), $this->input(2))->assertOk();
$this->postJson($this->url(), $this->input(3, 'resolve'))->assertOk()->assertJsonPath('handling.status', 'resolved');
$this->postJson($this->url(), $this->input(4, 'reopen'))->assertOk()->assertJsonPath('handling.status', 'open')->assertJsonPath('handling.assignee_user_id', null);
$this->postJson($this->url(), $this->input(5, 'claim'))->assertOk();
$this->postJson($this->url(), $this->input(6, 'release'))->assertOk()->assertJsonPath('handling.status', 'open');
$this->assertSame($before, $this->opportunity->fresh()->getRawOriginal());
$this->assertSame(0, DB::table('tasks')->count());
$this->assertSame($grantCount, DB::table('portal_role_assignments')->count());
$raw = DB::table('audit_events')->where('source_system', 'portal-opportunity-support')->get()->toJson();
$this->assertStringNotContainsString('PRIVATE SYNTHETIC treatment note', $raw);
$this->get($this->url())->assertOk()->assertHeader('Cache-Control', 'no-store, private')->assertInertia(fn (Assert $page) => $page->has('history.data', 7)->where('incident.handling.version', 7)->where('history.data.0.note_available', true)->where('history.data.0.note', 'PRIVATE SYNTHETIC treatment note with evidence.')->missing('history.data.0.operation_key')->missing('history.data.0.hash'));
$mine = app(OpportunityTechnicalIncidents::class)->recent($this->reporter, $this->opportunity)[0];
$this->assertSame('open', $mine['handling']['status']);
$this->assertArrayNotHasKey('note', $mine['handling']);
$this->assertArrayNotHasKey('assignee_user_id', $mine['handling']);
}
public function test_repeating_the_original_command_after_later_treatment_is_readonly_and_private_to_its_actor(): void
{
$input = $this->input(0, 'claim');
$this->postJson($this->url(), $input)->assertOk();
$this->postJson($this->url(), $this->input(1, 'resolve'))->assertOk();
$this->postJson($this->url(), $input)->assertOk()->assertJsonPath('replayed', true)->assertJsonPath('result_version', 1)->assertJsonPath('handling.version', 2)->assertJsonPath('handling.status', 'resolved');
$this->assertSame(2, DB::table('audit_events')->where('source_system', 'portal-opportunity-support')->count());
$this->get($this->url())->assertInertia(fn (Assert $page) => $page->has('commands', 2)->where('commands.0.key', $input['operation_key'])->missing('commands.0.hash'));
$this->actingAs($this->colleague)->get($this->url())->assertInertia(fn (Assert $page) => $page->has('commands', 0)->has('history.data', 2));
}
public function test_changed_command_and_stale_preparation_are_rejected_while_canonical_replay_is_accepted(): void
{
$input = $this->input(0);
$this->postJson($this->url(), [...$input, 'operation_key' => strtoupper($input['operation_key']), 'version' => '0', 'note' => ' '.$input['note'].' '])->assertOk();
$this->postJson($this->url(), $input)->assertOk()->assertJsonPath('replayed', true);
$this->postJson($this->url(), [...$input, 'note' => 'SYNTHETIC different prepared note'])->assertConflict();
$this->postJson($this->url(), $this->input(0, 'resolve'))->assertConflict();
$this->assertSame(1, DB::table('audit_events')->where('source_system', 'portal-opportunity-support')->count());
}
public function test_invalid_state_changes_and_whitespace_only_notes_do_not_append_events(): void
{
$this->postJson($this->url(), $this->input(0, 'reopen'))->assertUnprocessable();
$this->postJson($this->url(), $this->input(0, 'release'))->assertUnprocessable();
$this->postJson($this->url(), [...$this->input(0), 'note' => str_repeat(' ', 25)])->assertUnprocessable();
$this->postJson($this->url(), [...$this->input(0), 'action' => 'publish'])->assertUnprocessable();
$this->postJson($this->url(), $this->input(0, 'claim'))->assertOk();
$this->postJson($this->url(), $this->input(1, 'claim'))->assertUnprocessable();
$this->postJson($this->url(), $this->input(1, 'resolve'))->assertOk();
$this->postJson($this->url(), $this->input(2, 'resolve'))->assertUnprocessable();
$this->postJson($this->url(), $this->input(2, 'claim'))->assertUnprocessable();
$this->postJson($this->url(), $this->input(2))->assertOk()->assertJsonPath('handling.status', 'resolved');
}
public function test_current_scope_revocation_and_auditor_restriction_apply_to_reads_and_replayed_commands(): void
{
$input = $this->input(0);
$this->postJson($this->url(), $input)->assertOk();
DB::table('portal_role_assignments')->where('user_id', $this->support->id)->update(['revoked_at' => now()]);
$this->get($this->url())->assertForbidden();
$this->postJson($this->url(), $input)->assertForbidden();
$this->actingAs($this->colleague);
$auditor = (string) Str::uuid();
app(PortalProfileAdministration::class)->insertGrant($auditor, $this->reporter, ['user_id' => $this->colleague->id, 'role' => 'auditor', 'opportunity_types' => ['investment_project']]);
$this->get($this->url())->assertForbidden();
$this->postJson($this->url(), $this->input(1))->assertForbidden();
DB::table('portal_role_assignments')->where('id', $auditor)->update(['revoked_at' => now()]);
DB::table('opportunities')->where('id', $this->opportunity->id)->update(['opportunity_category' => 'real_estate']);
$this->get($this->url())->assertNotFound();
$this->postJson($this->url(), $this->input(1))->assertNotFound();
}
public function test_reporter_sees_status_without_internal_notes_and_cannot_open_support_detail(): void
{
$this->postJson($this->url(), $this->input(0, 'resolve'))->assertOk();
$this->actingAs($this->reporter)->get($this->url())->assertForbidden();
$this->postJson($this->url(), $this->input(1, 'reopen'))->assertForbidden();
$rows = app(OpportunityTechnicalIncidents::class)->recent($this->reporter, $this->opportunity);
$this->assertSame('resolved', $rows[0]['handling']['status']);
$this->assertStringNotContainsString('PRIVATE SYNTHETIC treatment note', json_encode($rows));
$this->assertArrayNotHasKey('commands', $rows[0]);
}
public function test_history_pagination_preserves_queue_context_and_an_unreadable_note_is_not_invented(): void
{
for ($i = 0; $i < 12; $i++) {
$this->postJson($this->url(), $this->input($i))->assertOk();
}
$this->get($this->url().'?page=3')->assertOk()->assertInertia(fn (Assert $page) => $page->has('history.data', 10)->where('history.total', 12)->where('history.data.0.version', 12)->where('backUrl', '/suporte-tecnico/incidentes?page=3')->where('history.next_page_url', fn (string $url) => str_contains($url, 'page=3') && str_contains($url, 'history_page=2')));
$this->get($this->url().'?page=3&history_page=2')->assertInertia(fn (Assert $page) => $page->has('history.data', 2)->where('history.data.0.version', 2));
DB::table('audit_events')->insert(['source_system' => 'portal-opportunity-support', 'entity_type' => 'opportunity-technical-incident', 'entity_id' => $this->reference, 'action' => 'incident_handled', 'actor_user_id' => $this->support->id, 'data_classification' => 'restricted', 'after_data' => json_encode(['handling' => ['version' => 13, 'status' => 'open', 'assignee_user_id' => null, 'updated_at' => now()->toIso8601String(), 'resolved_at' => null, 'command' => 'note', 'note' => 'SYNTHETIC unreadable envelope']])]);
$this->get($this->url())->assertOk()->assertInertia(fn (Assert $page) => $page->where('incident.handling.version', 13)->where('history.data.0.note_available', false)->where('history.data.0.note', null));
}
public function test_invalid_current_state_is_preserved_and_does_not_silently_restart_treatment(): void
{
DB::table('audit_events')->insert(['source_system' => 'portal-opportunity-support', 'entity_type' => 'opportunity-technical-incident', 'entity_id' => $this->reference, 'action' => 'incident_handled', 'actor_user_id' => $this->support->id, 'data_classification' => 'restricted', 'after_data' => json_encode(['handling' => ['version' => 5, 'status' => 'unrecognized']])]);
$this->get($this->url())->assertStatus(503);
$this->postJson($this->url(), $this->input(0))->assertStatus(503);
$this->get('/suporte-tecnico/incidentes')->assertOk()->assertInertia(fn (Assert $page) => $page->where('incidents.data.0.handling', null));
$this->assertNull(app(OpportunityTechnicalIncidents::class)->recent($this->reporter, $this->opportunity)[0]['handling']);
$this->actingAs($this->reporter)->get('/minhas-oportunidades/'.$this->opportunity->id.'/view')->assertOk();
$this->assertSame(1, DB::table('audit_events')->where('source_system', 'portal-opportunity-support')->count());
}
public function test_queue_filters_use_the_latest_treatment_and_detail_keeps_the_queue_context(): void
{
$this->postJson($this->url(), $this->input(0, 'claim'))->assertOk();
$this->get('/suporte-tecnico/incidentes?status=in_progress')->assertOk()->assertInertia(fn (Assert $page) => $page->where('incidents.total', 1)->where('incidents.data.0.handling.status', 'in_progress'));
$this->postJson($this->url(), $this->input(1, 'resolve'))->assertOk();
$this->postJson($this->url(), $this->input(2))->assertOk();
$this->get('/suporte-tecnico/incidentes?status=open')->assertInertia(fn (Assert $page) => $page->where('incidents.total', 0));
$this->get('/suporte-tecnico/incidentes?status=resolved')->assertInertia(fn (Assert $page) => $page->where('incidents.total', 1)->where('incidents.data.0.details_url', $this->url()));
$this->get($this->url().'?status=resolved&page=3')->assertInertia(fn (Assert $page) => $page->where('backUrl', '/suporte-tecnico/incidentes?page=3&status=resolved'));
}
public function test_audit_failure_rolls_back_the_treatment_and_the_same_command_can_be_retried_explicitly(): void
{
DB::unprepared("CREATE FUNCTION qa_fail_handling() RETURNS trigger LANGUAGE plpgsql AS \$\$ BEGIN IF NEW.source_system='portal-opportunity-support' THEN RAISE EXCEPTION 'PRIVATE SYNTHETIC HANDLING FAILURE'; END IF; RETURN NEW; END \$\$; CREATE TRIGGER qa_fail_handling BEFORE INSERT ON audit_events FOR EACH ROW EXECUTE FUNCTION qa_fail_handling();");
$input = $this->input(0, 'claim');
$response = $this->postJson($this->url(), $input)->assertStatus(503);
$this->assertStringNotContainsString('PRIVATE SYNTHETIC HANDLING FAILURE', $response->getContent());
$this->assertSame(0, app(TechnicalIncidentHandling::class)->stateFor($this->reference)['version']);
DB::unprepared('DROP TRIGGER qa_fail_handling ON audit_events; DROP FUNCTION qa_fail_handling();');
$this->postJson($this->url(), $input)->assertOk()->assertJsonPath('result_version',1);
}
}