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

355 lines
23 KiB
PHP

<?php
namespace Tests\Feature;
use App\Models\Opportunity;
use App\Models\User;
use App\Services\InvestmentCatalogue;
use App\Services\InvestorContext;
use App\Services\OpportunityClassification;
use App\Services\OpportunityManagement;
use App\Services\ProactiveInvestment;
use Illuminate\Foundation\Testing\DatabaseTransactions;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Str;
use Tests\Support\ProactiveInvestmentFixture as F;
use Tests\TestCase;
final class ProactiveInvestmentTest extends TestCase
{
use DatabaseTransactions;
private User $actor;
protected function setUp(): void
{
parent::setUp();
F::guard();
$this->withoutVite();
config(['portal.profile_access_enabled' => true, 'portal.opportunity_specialized_drafts_enabled' => true, 'portal.managers' => [], 'investor-path.enabled' => true, 'suitecrm.enabled' => false, 'investmoz_ai.enabled' => false]);
$this->actor = F::operator();
$this->login($this->actor);
}
private function login(User $u): void
{
$this->actingAs($u)->withSession(['portal.mfa_actor' => $u->id, 'portal.mfa_at' => time(), 'portal.access_revision' => (int) $u->portal_access_revision]);
}
private function create(string $pilot = 'Cahora Bassa'): Opportunity
{
$response = $this->postJson('/minhas-oportunidades/drafts', F::input($pilot));
if ($response->status() !== 201) {
fwrite(STDERR, substr($response->getContent(), 0, 400)."\n");
}$id = $response->assertCreated()->json('id');
return Opportunity::findOrFail($id);
}
private function publication(Opportunity $o, array $patch = []): array
{
$a = app(OpportunityManagement::class)->values($o)['proactive'];
return [...['etag' => app(OpportunityManagement::class)->etag($o), 'action' => 'disclose', 'title' => $o->title, 'summary' => 'SYNTHETIC QA ideia em preparação; não é oportunidade com participação disponível.', 'description' => 'Conteúdo público expressamente redigido.', 'limitations' => 'Conclusões locais por confirmar.', 'source_reference' => 'SYNTHETIC QA exemplo público', 'edition' => 'QA1', 'source_date' => '2026-10-27', 'sector_id' => $o->sector_id, 'territory_ids' => [$o->primary_territory_id]], ...F::publication($a), ...$patch];
}
private function publish(Opportunity $o): array
{
$p = $this->postJson('/minhas-oportunidades/'.$o->id.'/divulgacao/previa', $this->publication($o))->assertOk()->json();
$input = ['token' => $p['token'], 'operation_key' => (string) Str::uuid(), 'confirm' => true, 'reason' => 'SYNTHETIC QA explicit public projection reviewed without private information'];
$this->postJson('/minhas-oportunidades/'.$o->id.'/divulgacao/confirmar', $input)->assertOk();
return $input;
}
public function test_workforce_evidence_saves_recovers_and_requires_existing_explicit_publication(): void
{
$input = F::input();
$input['proactive']['evidence'][0]['category'] = 'workforce';
$id = $this->postJson('/minhas-oportunidades/drafts', $input)->assertCreated()->json('id');
$o = Opportunity::findOrFail($id);
$analysis = app(OpportunityManagement::class)->values($o)['proactive'];
$this->assertSame('workforce', $analysis['evidence'][0]['category']);
$this->assertNull(app(ProactiveInvestment::class)->publicData($o));
$this->publish($o);
$this->getJson('/api/v1/investment/map?opportunity_id='.$id)->assertOk()
->assertJsonPath('thematic_records.0.evidence.0.category', 'workforce')
->assertJsonPath('thematic_records.0.evidence.0.source', $analysis['evidence'][0]['source'])
->assertJsonCount(1, 'thematic_records.0.evidence')->assertJsonCount(0, 'features');
$analysis['evidence'][0]['category'] = 'demand';
$this->putJson('/minhas-oportunidades/'.$id.'/draft', ['etag' => app(OpportunityManagement::class)->etag($o->fresh()), 'proactive' => $analysis,
'reason' => 'SYNTHETIC QA later private changes must not replace the published workforce source'])->assertOk();
$this->getJson('/api/v1/investment/map?opportunity_id='.$id)->assertOk()->assertJsonPath('thematic_records.0.evidence.0.category', 'workforce');
}
public function test_legacy_records_without_analysis_add_no_publication_lookup(): void
{
$o = new Opportunity;
$o->forceFill(['workflow_context' => []]);
DB::enableQueryLog();
DB::flushQueryLog();
$this->assertNull(app(ProactiveInvestment::class)->publicData($o));
$this->assertSame([], DB::getQueryLog());
DB::disableQueryLog();
}
public function test_create_recover_edit_with_etag_and_historical_foundation_without_financial_or_promoter_requirements(): void
{
$o = $this->create();
$a = app(OpportunityManagement::class)->values($o)['proactive'];
$tag = app(OpportunityManagement::class)->etag($o);
$this->assertNull($o->estimated_investment_amount);
$this->assertNull($o->promoter_name);
$this->assertNotNull($o->primary_territory_id);
$this->get('/minhas-oportunidades/'.$o->id.'/edit')->assertOk()->assertInertia(fn ($page) => $page->where('values.proactive.need.state', 'hypothesis')->has('proactiveHistory', 1));
$a['need']['state'] = 'confirmed';
$a['need']['confirmation'] = 'SYNTHETIC QA confirmation of fixture conditions only, no real market validation.';
$this->putJson('/minhas-oportunidades/'.$o->id.'/draft', ['etag' => $tag, 'proactive' => $a, 'reason' => 'SYNTHETIC QA updated documented foundation for this fixture only'])->assertOk();
$this->assertSame('confirmed', app(OpportunityManagement::class)->values($o->fresh())['proactive']['need']['state']);
$history = app(ProactiveInvestment::class)->history($o);
$this->assertCount(2, $history);
$this->assertSame('hypothesis', $history[1]['state']);
$this->putJson('/minhas-oportunidades/'.$o->id.'/draft', ['etag' => $tag, 'proactive' => $a, 'reason' => 'SYNTHETIC QA stale version cannot overwrite current analysis'])->assertStatus(409);
}
public function test_hypothesis_cannot_be_confirmed_without_documented_conditions_and_missing_is_not_zero(): void
{
$o = $this->create();
$a = app(OpportunityManagement::class)->values($o)['proactive'];
$a['need']['state'] = 'confirmed';
$a['need']['observed_demand'] = '';
$this->putJson('/minhas-oportunidades/'.$o->id.'/draft', ['etag' => app(OpportunityManagement::class)->etag($o), 'proactive' => $a, 'reason' => 'SYNTHETIC QA incomplete confirmation must preserve prior hypothesis'])->assertUnprocessable();
$this->assertSame('hypothesis', app(OpportunityManagement::class)->values($o->fresh())['proactive']['need']['state']);
$a['need']['state'] = 'hypothesis';
$a['idea']['resources'] = '';
$this->putJson('/minhas-oportunidades/'.$o->id.'/draft', ['etag' => app(OpportunityManagement::class)->etag($o), 'proactive' => $a, 'reason' => 'SYNTHETIC QA unknown resource remains blank rather than zero'])->assertOk();
$this->assertNull(app(OpportunityManagement::class)->values($o->fresh())['proactive']['idea']['resources']);
}
public function test_methodology_alone_cannot_confirm_local_need(): void
{
$o = $this->create();
$a = app(OpportunityManagement::class)->values($o)['proactive'];
$a['need']['state'] = 'confirmed';
$a['evidence'][0]['purpose'] = 'methodology';
$this->putJson('/minhas-oportunidades/'.$o->id.'/draft', ['etag' => app(OpportunityManagement::class)->etag($o), 'proactive' => $a, 'reason' => 'SYNTHETIC QA international methodology is not evidence of local demand'])->assertUnprocessable();
$this->assertSame('hypothesis', app(OpportunityManagement::class)->values($o->fresh())['proactive']['need']['state']);
}
public function test_creation_replay_and_duplicate_evidence_and_relations_do_not_duplicate_analysis(): void
{
$input = F::input();
$id = $this->postJson('/minhas-oportunidades/drafts', $input)->assertCreated()->json('id');
$this->postJson('/minhas-oportunidades/drafts', $input)->assertCreated()->assertJsonPath('id', $id);
$o = Opportunity::findOrFail($id);
$a = $input['proactive'];
$a['evidence'][] = [...$a['evidence'][0], 'id' => (string) Str::uuid()];
$this->putJson('/minhas-oportunidades/'.$id.'/draft', ['etag' => app(OpportunityManagement::class)->etag($o), 'proactive' => $a, 'reason' => 'SYNTHETIC QA repeated evidence must be reconciled, not counted twice'])->assertUnprocessable();
$this->assertSame(1, Opportunity::where('management_creation_key', $input['creation_key'])->count());
}
public function test_document_from_another_scope_cannot_be_attached_or_reclassified_as_public(): void
{
$o = $this->create();
$a = app(OpportunityManagement::class)->values($o)['proactive'];
$a['evidence'][0]['document_id'] = (string) Str::uuid();
$this->putJson('/minhas-oportunidades/'.$o->id.'/draft', ['etag' => app(OpportunityManagement::class)->etag($o), 'proactive' => $a, 'reason' => 'SYNTHETIC QA document association must use existing access rules'])->assertUnprocessable();
}
public function test_existing_permissions_reject_unassigned_and_unrelated_operators(): void
{
$o = $this->create();
$this->login(User::factory()->create());
$this->postJson('/minhas-oportunidades/drafts', F::input())->assertForbidden();
$this->get('/minhas-oportunidades/'.$o->id.'/edit')->assertForbidden();
$this->login(F::operator());
$this->get('/minhas-oportunidades/'.$o->id.'/edit')->assertForbidden();
$this->login($this->actor);
$input = F::input();
unset($input['proactive']);
$this->postJson('/minhas-oportunidades/drafts', $input)->assertForbidden();
$this->assertNull($o->organization_id);
}
public function test_internal_attached_document_cannot_be_changed_to_public_through_analysis(): void
{
$o = $this->create();
$doc = (string) Str::uuid();
$hash = hash('sha256', 'SYNTHETIC QA private source');
DB::table('documents')->insert(['id' => $doc, 'file_name' => 'SYNTHETIC-QA-INTERNAL-LIST.txt', 'storage_key' => 'synthetic-qa/'.$doc, 'media_type' => 'text/plain', 'sha256' => $hash, 'size_bytes' => strlen('SYNTHETIC QA private source'), 'uploaded_by' => $this->actor->id, 'status' => 'active', 'data_classification' => 'internal', 'malware_scan_status' => 'clean', 'malware_scanned_sha256' => $hash]);
DB::table('opportunity_documents')->insert(['id' => (string) Str::uuid(), 'opportunity_id' => $o->id, 'document_id' => $doc, 'version' => 1, 'created_by' => $this->actor->id, 'payload' => json_encode(['title' => 'SYNTHETIC QA internal list', 'visibility' => 'internal'])]);
$a = app(OpportunityManagement::class)->values($o)['proactive'];
$a['evidence'][0]['document_id'] = $doc;
$a['evidence'][0]['access'] = 'public';
$this->putJson('/minhas-oportunidades/'.$o->id.'/draft', ['etag' => app(OpportunityManagement::class)->etag($o), 'proactive' => $a, 'reason' => 'SYNTHETIC QA original document restrictions cannot be overridden here'])->assertUnprocessable();
$this->assertSame('internal', DB::table('documents')->where('id', $doc)->value('data_classification'));
}
public function test_selected_public_relationship_and_assumptions_travel_without_private_reference_or_duplicate(): void
{
$o = $this->create();
$a = app(OpportunityManagement::class)->values($o)['proactive'];
$target = app(OpportunityClassification::class)->targets('project')->first(['id', 'name']);
$this->assertNotNull($target);
$r = ['id' => (string) Str::uuid(), 'kind' => 'project', 'target_id' => $target->id, 'relation' => 'hypothesis', 'reference' => 'PRIVATE-QA-RELATION-REFERENCE-NOT-FOR-PUBLICATION', 'evidence_ids' => [$a['evidence'][0]['id']]];
$a['relations'] = [$r];
$this->putJson('/minhas-oportunidades/'.$o->id.'/draft', ['etag' => app(OpportunityManagement::class)->etag($o), 'proactive' => $a, 'reason' => 'SYNTHETIC QA reference is a hypothesis, not access or contract'])->assertOk();
$o->refresh();
$bad = $a;
$bad['relations'][] = [...$r, 'id' => (string) Str::uuid()];
$this->putJson('/minhas-oportunidades/'.$o->id.'/draft', ['etag' => app(OpportunityManagement::class)->etag($o), 'proactive' => $bad, 'reason' => 'SYNTHETIC QA same reference cannot appear twice in the same analysis'])->assertUnprocessable();
$d = $this->publication($o);
$d['proactive_public'] = [...$d['proactive_public'], 'relation_ids' => [$r['id']], 'resources' => 'SYNTHETIC QA public resource, no guaranteed capacity.', 'assumptions' => 'No contract, land or power guaranteed.', 'conditions' => 'Confirm actual local evidence.'];
$p = $this->postJson('/minhas-oportunidades/'.$o->id.'/divulgacao/previa', $d)->assertOk()->json();
$this->assertSame($target->name, $p['projection']['proactive']['relations'][0]['label']);
$this->assertStringNotContainsString('PRIVATE-QA-RELATION', json_encode($p['projection']));
$this->postJson('/minhas-oportunidades/'.$o->id.'/divulgacao/confirmar', ['token' => $p['token'], 'operation_key' => (string) Str::uuid(), 'confirm' => true, 'reason' => 'SYNTHETIC QA explicitly reviewed public relationship and assumptions'])->assertOk();
$ref = app(InvestorContext::class)->reference('opportunity:'.$o->id);
$this->assertSame('hypothesis', $ref['analysis']['proactive']['relations'][0]['relationship']);
$this->assertSame('No contract, land or power guaranteed.', $ref['analysis']['proactive']['assumptions']);
}
public function test_publication_needs_explicit_review_and_rejects_internal_source_and_literal_leakage(): void
{
$o = $this->create();
$data = $this->publication($o);
unset($data['proactive_reviewed']);
$this->postJson('/minhas-oportunidades/'.$o->id.'/divulgacao/previa', $data)->assertUnprocessable();
$data = $this->publication($o);
$a = app(OpportunityManagement::class)->values($o)['proactive'];
$data['proactive_public']['evidence_ids'] = [$a['evidence'][1]['id']];
$this->postJson('/minhas-oportunidades/'.$o->id.'/divulgacao/previa', $data)->assertUnprocessable();
$data = $this->publication($o, ['description' => $a['evidence'][1]['statement']]);
$this->postJson('/minhas-oportunidades/'.$o->id.'/divulgacao/previa', $data)->assertUnprocessable();
$data = $this->publication($o);
$data['proactive_public']['state'] = 'confirmed';
$this->postJson('/minhas-oportunidades/'.$o->id.'/divulgacao/previa', $data)->assertUnprocessable();
}
public function test_public_snapshot_separate_from_private_edits_and_anonymous_access_no_coordinates_required(): void
{
$o = $this->create();
$private = $o->management_draft;
$confirm = $this->publish($o);
$this->postJson('/minhas-oportunidades/'.$o->id.'/divulgacao/confirmar', $confirm)->assertOk()->assertJsonPath('replayed', true);
$o->refresh();
$this->assertSame($private, $o->management_draft);
$this->assertFalse(app(ProactiveInvestment::class)->publicData($o)['economic_validation']);
$a = app(OpportunityManagement::class)->values($o)['proactive'];
$a['idea']['solution'] = 'PRIVATE-QA-NEW-UNPUBLISHED-SOLUTION';
$this->putJson('/minhas-oportunidades/'.$o->id.'/draft', ['etag' => app(OpportunityManagement::class)->etag($o), 'proactive' => $a, 'reason' => 'SYNTHETIC QA private update must not change published projection'])->assertOk();
$this->app['auth']->forgetGuards();
$this->get('/oportunidades/'.$o->slug)->assertOk()->assertDontSee('PRIVATE-QA-');
$this->get('/minhas-oportunidades/'.$o->id.'/edit')->assertRedirect('/login');
$this->assertNotNull(app(ProactiveInvestment::class)->publicData($o->fresh()));
}
public function test_three_synthetic_pilots_reuse_existing_territories_and_catalogue_groups(): void
{
foreach (['Cahora Bassa', 'Montepuez', 'Gurué'] as $pilot) {
$o = $this->create($pilot);
$this->publish($o);
$this->assertNotNull($o->fresh()->primary_territory_id);
$this->assertTrue(app(InvestmentCatalogue::class)->query(['perspective' => 'ideas'])->whereKey($o->id)->exists());
$this->assertFalse(app(InvestmentCatalogue::class)->query(['perspective' => 'needs'])->whereKey($o->id)->exists());
}
$this->get('/oportunidades?perspective=ideas')->assertOk();
}
public function test_existing_facts_and_needs_do_not_require_irrelevant_idea_fields(): void
{
foreach (['exists', 'needs'] as $kind) {
$input = F::input();
$input['proactive']['kind'] = $kind;
$id = $this->postJson('/minhas-oportunidades/drafts', $input)->assertCreated()->json('id');
$o = Opportunity::findOrFail($id);
$d = $this->publication($o);
unset($d['proactive_public']['proposal']);
if ($kind === 'exists') {
unset($d['proactive_public']['need']);
}
$p = $this->postJson('/minhas-oportunidades/'.$id.'/divulgacao/previa', $d)->assertOk()->json();
$this->assertSame($kind, $p['projection']['proactive']['group']);
$this->assertSame('', $p['projection']['proactive']['proposal']);
$this->postJson('/minhas-oportunidades/'.$id.'/divulgacao/confirmar', ['token' => $p['token'], 'operation_key' => (string) Str::uuid(), 'confirm' => true, 'reason' => 'SYNTHETIC QA only applicable questions for this content group'])->assertOk();
$this->assertTrue(app(InvestmentCatalogue::class)->query(['perspective' => $kind])->whereKey($id)->exists());
}
}
public function test_private_analysis_complements_keep_preparation_valid_without_inventing_public_offers(): void
{
$private = $this->create('Gurué');
$public = $this->create();
$input = $this->publication($public, ['summary' => 'SYNTHETIC QA: silos e armazenagem como componente documentada.']);
$plan = $this->postJson('/minhas-oportunidades/'.$public->id.'/divulgacao/previa', $input)->assertOk()->json();
$this->postJson('/minhas-oportunidades/'.$public->id.'/divulgacao/confirmar', ['token' => $plan['token'], 'operation_key' => (string) Str::uuid(), 'confirm' => true, 'reason' => 'SYNTHETIC QA public component in transaction only'])->assertOk();
$result = $this->postJson('/complementos-investimento', ['contexts' => [['origin' => 'opportunity:'.$private->id], ['origin' => 'opportunity:'.$public->id]]])->assertOk()->json();
$this->assertSame([], $result['issues']);
$this->assertNotEmpty($result['items']);
foreach ($result['items'] as $item) {
$this->assertSame('opportunity:'.$public->id, $item['origin']['key']);
}
$this->assertStringNotContainsString('PRIVATE-QA-', json_encode($result));
$this->assertFalse(Opportunity::published()->whereKey($private->id)->exists());
$this->assertSame('draft', $private->fresh()->status);
}
public function test_private_analysis_complements_still_hide_unauthorised_sources(): void
{
$private = $this->create('Gurué');
$this->login(User::factory()->create());
$this->postJson('/complementos-investimento', ['origin' => 'opportunity:'.$private->id])->assertNotFound();
$result = $this->postJson('/complementos-investimento', ['contexts' => [['origin' => 'opportunity:'.$private->id]]])->assertOk()->json();
$this->assertSame([], $result['items']);
$this->assertCount(1, $result['issues']);
$this->assertStringNotContainsString($private->title, json_encode($result));
$this->assertStringNotContainsString('PRIVATE-QA-', json_encode($result));
}
public function test_authenticated_multi_idea_preparation_keeps_each_origin_and_deliberate_edits_in_existing_dossier(): void
{
$first = $this->create();
$second = $this->create('Gurué');
$this->publish($first);
$this->publish($second);
$investor = User::factory()->create();
$this->login($investor);
$key = (string) Str::uuid();
$create = ['title' => 'SYNTHETIC QA deliberately chosen project title', 'client_key' => $key, 'reuse_consent' => true];
$id = $this->postJson('/investimentos', $create)->assertCreated()->json('id');
$this->postJson('/investimentos', $create)->assertCreated()->assertJsonPath('id', $id);
$before = $this->getJson('/investimentos/'.$id.'/contexto')->assertOk()->json();
$input = ['version' => $before['case']['version'], 'selection' => [], 'opportunity_choices' => array_map(fn ($o) => ['source' => 'opportunity:'.$o->id, 'territory' => 'territory:'.$o->primary_territory_id], [$first, $second])];
$preview = $this->postJson('/investimentos/'.$id.'/contexto/previa', $input)->assertOk()->json();
$this->assertCount(2, $preview['opportunity_choices']);
$this->assertSame('SYNTHETIC QA deliberately chosen project title', $preview['fields']['project_title']);
foreach ($preview['opportunity_choices'] as $c) {
$this->assertSame('ideas', $c['opportunity']['analysis']['proactive']['group']);
$this->assertStringNotContainsString('PRIVATE-QA-', json_encode($c));
}
$confirm = [...$input, 'preview_hash' => $preview['preview_hash'], 'operation_key' => (string) Str::uuid(), 'confirm' => true];
$this->postJson('/investimentos/'.$id.'/contexto/confirmar', $confirm)->assertOk();
$this->postJson('/investimentos/'.$id.'/contexto/confirmar', $confirm)->assertOk()->assertJsonPath('replayed', true);
$saved = $this->getJson('/investimentos/'.$id.'/contexto')->assertOk()->json();
$this->assertCount(2, $saved['context']['opportunity_choices']);
$consumers = [];
foreach ([$first, $second] as $record) {
$consumer = $this->getJson('/investimentos/'.$id.'/contexto?opportunity=opportunity:'.$record->id)->assertOk()->json();
$snapshot = json_decode($consumer['prefill']['simulator']['context_applicability_snapshot'], true);
$this->assertCount(1, $snapshot['opportunity_choices']);
$this->assertSame('opportunity:'.$record->id, $snapshot['opportunity_choices'][0]['opportunity']['key']);
$this->assertSame('ideas', $snapshot['opportunity_choices'][0]['opportunity']['analysis']['proactive']['group']);
$this->assertArrayNotHasKey('tax_benefit', $consumer['prefill']['simulator']);
$consumers[] = $consumer;
}$saved['consumer_contexts'] = $consumers;
$evidenceDirectory = base_path('outputs/proactive-investment-20261027');
if (! is_dir($evidenceDirectory)) {
mkdir($evidenceDirectory, 0777, true);
}
file_put_contents($evidenceDirectory.'/recovered-context.json', json_encode($saved, JSON_UNESCAPED_UNICODE | JSON_THROW_ON_ERROR));
$this->get('/investimentos/'.$id)->assertOk();
$this->login($this->actor);
$this->getJson('/investimentos/'.$id.'/contexto')->assertForbidden();
}
}