160 lines
18 KiB
PHP
160 lines
18 KiB
PHP
<?php
|
|
namespace Tests\Feature;
|
|
|
|
use App\Models\Opportunity;
|
|
use App\Models\User;
|
|
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_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(\App\Services\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(\App\Services\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(\App\Services\InvestmentCatalogue::class)->query(['perspective'=>'ideas'])->whereKey($o->id)->exists());$this->assertFalse(app(\App\Services\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(\App\Services\InvestmentCatalogue::class)->query(['perspective'=>$kind])->whereKey($id)->exists());
|
|
}
|
|
}
|
|
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;
|
|
file_put_contents(base_path('outputs/proactive-investment-20261027/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();
|
|
}
|
|
}
|