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