176 lines
14 KiB
PHP
176 lines
14 KiB
PHP
<?php
|
|
|
|
namespace Tests\Feature;
|
|
|
|
use App\Models\Opportunity;
|
|
use App\Models\User;
|
|
use App\Services\InvestorContext;
|
|
use App\Services\OfferPublicCatalogue;
|
|
use App\Services\OpportunityManagement;
|
|
use App\Services\PortalProfileAdministration;
|
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
|
use Illuminate\Support\Facades\DB;
|
|
use Illuminate\Support\Facades\Mail;
|
|
use Illuminate\Support\Str;
|
|
use Tests\TestCase;
|
|
|
|
/** Synthetic acceptance regression for the existing investment-context boundary. */
|
|
final class OfferInvestmentAcceptanceAuditTest extends TestCase
|
|
{
|
|
use RefreshDatabase;
|
|
|
|
private function prepare(array $location = []): array
|
|
{
|
|
$this->withoutVite();
|
|
config(['portal.profile_access_enabled' => true]);
|
|
$owner = User::factory()->create();
|
|
app(PortalProfileAdministration::class)->insertGrant((string) Str::uuid(), User::factory()->create(), [
|
|
'user_id' => $owner->id, 'role' => 'promoter', 'organization_id' => null,
|
|
'opportunity_types' => ['business_sale'], 'source' => 'synthetic-acceptance-audit',
|
|
]);
|
|
$payload = ['creation_key' => (string) Str::uuid(), 'opportunity_category' => 'business_sale',
|
|
'title' => 'Synthetic acquisition acceptance', 'description' => 'Existing company description to reuse once in investment preparation.',
|
|
'private_offer' => ['price_mode' => 'amount', 'amount' => '120000.00', 'currency' => 'MZN',
|
|
'business' => ['revenue' => '60000.00', 'cash_flow' => '20000.00', 'currency' => 'MZN']],
|
|
'reason' => 'Synthetic final acceptance integration audit only.', ...$location];
|
|
$record = $this->actingAs($owner)->postJson('/minhas-oportunidades/ofertas/rascunhos', $payload)->assertCreated()->json('record');
|
|
|
|
return [$owner, $record, $payload];
|
|
}
|
|
|
|
public function test_saved_offer_reuses_its_description_in_the_existing_investment_context(): void
|
|
{
|
|
[$owner, $record, $payload] = $this->prepare();
|
|
$reference = app(InvestorContext::class)->reference('opportunity:'.$record['id']);
|
|
$this->assertSame('private', $reference['visibility']);
|
|
$this->assertSame($payload['title'], $reference['fields']['project_title']);
|
|
$this->assertSame($payload['description'], $reference['fields']['project_summary'] ?? null,
|
|
'Write once/read always: the existing description must not disappear at the investment-context boundary.');
|
|
$this->assertSame('/minhas-oportunidades/ofertas/'.$record['id'], $reference['href']);
|
|
$this->assertArrayNotHasKey('private_offer', $reference);
|
|
$this->assertArrayNotHasKey('revenue', $reference['fields']);
|
|
$this->assertArrayNotHasKey('currency', $reference['fields']);
|
|
}
|
|
|
|
public function test_native_context_options_do_not_disclose_another_owners_private_offer(): void
|
|
{
|
|
[$owner, $record, $payload] = $this->prepare();
|
|
$this->getJson('/minhas-oportunidades/ofertas/'.$record['id'])->assertOk()
|
|
->assertJsonPath('record.values.private_offer.business.revenue', '60000.00')
|
|
->assertJsonPath('record.values.private_offer.business.cash_flow', '20000.00');
|
|
$this->assertFalse(Opportunity::published()->whereKey($record['id'])->exists());
|
|
$this->actingAs(User::factory()->create());
|
|
$this->assertNotContains('opportunity:'.$record['id'], array_column(app(InvestorContext::class)->options(), 'key'));
|
|
$this->getJson('/minhas-oportunidades/ofertas/'.$record['id'])->assertForbidden();
|
|
}
|
|
|
|
public function test_private_description_is_not_truncated_and_does_not_replace_an_existing_summary(): void
|
|
{
|
|
[$owner, $record, $payload] = $this->prepare();
|
|
$description = str_repeat('Synthetic complete description. ', 400);
|
|
$this->postJson('/minhas-oportunidades/ofertas/'.$record['id'].'/guardar', [
|
|
'etag' => $record['etag'], 'description' => $description, 'reason' => $payload['reason'],
|
|
])->assertOk();
|
|
$this->assertSame(trim($description), app(InvestorContext::class)->reference('opportunity:'.$record['id'])['fields']['project_summary']);
|
|
DB::table('opportunities')->where('id', $record['id'])->update(['summary' => 'Deliberate existing summary']);
|
|
$this->assertSame('Deliberate existing summary', app(InvestorContext::class)->reference('opportunity:'.$record['id'])['fields']['project_summary']);
|
|
$this->assertSame(trim($description), Opportunity::findOrFail($record['id'])->description);
|
|
}
|
|
|
|
public function test_approved_offer_continues_in_same_dossier_location_analysis_saved_result_and_report_input(): void
|
|
{
|
|
Mail::fake();
|
|
config(['editorial_workflow.enabled' => true, 'investor-path.enabled' => true, 'suitecrm.enabled' => false, 'investmoz_ai.enabled' => false]);
|
|
$country = (string) Str::uuid();
|
|
$province = (string) Str::uuid();
|
|
foreach ([[$country, 'country', null], [$province, 'province', $country]] as [$id, $type, $parent]) {
|
|
DB::table('geo_locations')->insert(['id' => $id, 'code' => 'SYNTHETIC-'.$id, 'name' => 'Synthetic '.$type,
|
|
'location_type' => $type, 'parent_id' => $parent, 'data_classification' => 'public',
|
|
// Same isolated reference contract as OpportunitySearchFixture; never an operational import.
|
|
'source_system' => 'cartografia-workbook',
|
|
'external_id' => '964a187ad32ea553b9e112cd159caa90b900050c42b7329594a75bcd798a96ff:'.$id]);
|
|
}
|
|
[$owner, $record, $payload] = $this->prepare(['province_id' => $province]);
|
|
$contact = (string) Str::uuid();
|
|
DB::table('contacts')->insert(['id' => $contact, 'full_name' => 'Synthetic private contact', 'source_system' => 'portal-user-contact', 'external_id' => 'user:'.$owner->id]);
|
|
$url = '/minhas-oportunidades/ofertas/'.$record['id'];
|
|
$this->postJson($url.'/guardar', ['etag' => $record['etag'], 'private_offer' => [...$payload['private_offer'], 'contact_id' => $contact], 'reason' => $payload['reason']])->assertOk();
|
|
$this->postJson($url.'/submeter', ['etag' => app(OpportunityManagement::class)->etag(Opportunity::findOrFail($record['id'])), 'confirm' => true])->assertOk()->assertJsonPath('record.state', 'submitted');
|
|
$reviewer = User::factory()->withTwoFactor()->create();
|
|
app(PortalProfileAdministration::class)->insertGrant((string) Str::uuid(), User::factory()->create(), [
|
|
'user_id' => $reviewer->id, 'role' => 'validator', 'responsibilities' => ['validation_coordinator'],
|
|
'opportunity_types' => ['business_sale'], 'source' => 'synthetic-acceptance-audit',
|
|
]);
|
|
$this->actingAs($reviewer)->withSession(['portal.mfa_actor' => $reviewer->id, 'portal.mfa_at' => time(), 'portal.access_revision' => (int) $reviewer->fresh()->portal_access_revision]);
|
|
foreach (['start_review', 'approve'] as $action) {
|
|
$this->postJson($url.'/revisao', ['etag' => app(OpportunityManagement::class)->etag(Opportunity::findOrFail($record['id'])),
|
|
'action' => $action, 'reason' => 'Synthetic independent review for investment continuity.'])->assertOk();
|
|
}
|
|
$offer = Opportunity::findOrFail($record['id']);
|
|
$public = ['title' => 'Synthetic public acquisition', 'summary' => 'Approved synthetic public summary only.', 'category' => 'business_sale',
|
|
'province_id' => $province, 'district_id' => null, 'price_mode' => 'negotiable', 'amount' => null, 'currency' => null];
|
|
$hash = OfferPublicCatalogue::projectionHash($public);
|
|
$context = $offer->workflow_context;
|
|
$context['offer_publication'] = ['contract' => OfferPublicCatalogue::CONTRACT, 'qa_fixture' => true, 'revision' => 1, 'payload' => $public,
|
|
'approval' => ['state' => 'approved', 'hash' => $hash, 'actor_id' => $reviewer->id, 'at' => now()->subMinutes(2)->toIso8601String()],
|
|
'publication' => ['state' => 'published', 'hash' => $hash, 'actor_id' => User::factory()->create()->id, 'at' => now()->subMinute()->toIso8601String()]];
|
|
$offer->forceFill(['workflow_context' => $context])->save();
|
|
$this->getJson('/api/v1/offers')->assertOk()->assertJsonPath('results.total', 0);
|
|
$this->app->instance(OfferPublicCatalogue::class, new OfferPublicCatalogue(true));
|
|
$response = $this->getJson('/api/v1/offers/'.$offer->id)->assertOk()->assertJsonPath('data.summary', $public['summary']);
|
|
$this->assertStringNotContainsString($payload['description'], $response->getContent());
|
|
$this->assertStringNotContainsString('60000.00', $response->getContent());
|
|
$this->assertNull($offer->fresh()->published_at);
|
|
$this->actingAs($owner);
|
|
$case = $this->postJson('/investimentos', ['title' => $payload['title'], 'client_key' => (string) Str::uuid(), 'reuse_consent' => true])->assertCreated()->json('id');
|
|
$view = $this->getJson('/investimentos/'.$case.'/contexto')->assertOk()->json();
|
|
$body = ['version' => $view['case']['version'], 'selection' => ['opportunity' => 'opportunity:'.$offer->id], 'declarations' => ['currency' => 'MZN']];
|
|
$plan = $this->postJson('/investimentos/'.$case.'/contexto/previa', $body)->assertOk()->json();
|
|
$this->assertSame($payload['description'], $plan['fields']['project_summary']);
|
|
$this->postJson('/investimentos/'.$case.'/contexto/confirmar', [...$body, 'preview_hash' => $plan['preview_hash'], 'operation_key' => (string) Str::uuid(), 'confirm' => true])->assertOk();
|
|
$view = $this->getJson('/investimentos/'.$case.'/contexto')->assertOk()->json();
|
|
$prefill = $view['prefill']['simulator'];
|
|
$this->assertSame($province, $prefill['province_id']);
|
|
$this->assertSame($payload['description'], $prefill['project_summary']);
|
|
$this->assertStringNotContainsString('60000.00', json_encode($view, JSON_THROW_ON_ERROR));
|
|
$financial = ['version' => 1, 'data' => ['budgetPeriod' => 'Mensal',
|
|
'loan' => ['principal' => '100000', 'rate' => '0', 'months' => '24', 'rateType' => 'nominal', 'periodMonths' => '1', 'gracePeriods' => '0', 'upfrontFee' => '0', 'recurringFee' => '0'],
|
|
'breakEven' => ['fixed' => '3000', 'price' => '100', 'variable' => '50'], 'funding' => ['total' => '100000', 'secured' => '30000'],
|
|
'incomes' => [['label' => 'Synthetic scenario revenue', 'amount' => '10000']],
|
|
'expenses' => [['label' => 'Declared rent at synthetic province', 'amount' => '2000'], ['label' => 'Declared transport at synthetic province', 'amount' => '1000']]]];
|
|
$draft = $this->postJson('/api/v1/simulation-drafts', ['context' => $case, 'personal_snapshot' => [],
|
|
'economic_snapshot' => [...array_intersect_key($prefill, array_flip(['purpose', 'journey_type', 'project_title', 'project_summary', 'currency', 'province_id', 'context_applicability_snapshot'])),
|
|
'financial_tools_snapshot' => json_encode($financial, JSON_THROW_ON_ERROR)]],
|
|
['Idempotency-Key' => (string) Str::uuid()])->assertCreated()->json('data.id');
|
|
$saved = $this->getJson('/api/v1/simulation-drafts/'.$draft.'?context='.$case)->assertOk()->json('data.economic_snapshot');
|
|
$this->assertSame($payload['description'], $saved['project_summary']);
|
|
$path = $this->getJson('/investimentos/'.$case)->assertOk()->json();
|
|
$this->postJson('/investimentos/'.$case.'/resultados', ['version' => $path['case']['version'], 'draft_id' => $draft, 'draft_version' => 1,
|
|
'context_revision' => $path['investment_context']['revision'], 'context_reviewed' => true])->assertOk();
|
|
auth()->logout();
|
|
$this->actingAs(User::findOrFail($owner->id));
|
|
$path = $this->getJson('/investimentos/'.$case)->assertOk()->json();
|
|
$this->assertSame($draft, $path['results'][0]['draft_id']);
|
|
$this->assertSame($payload['description'], $path['investment_context']['fields']['project_summary']);
|
|
$this->assertSame(1, Opportunity::count());
|
|
$this->assertSame(1, DB::table('desk_cases')->count());
|
|
$this->assertStringNotContainsString('60000.00', json_encode($path, JSON_THROW_ON_ERROR));
|
|
$output = base_path('outputs/offers-investment-continuity-20261114');
|
|
if (! is_dir($output)) {
|
|
mkdir($output, 0777, true);
|
|
}
|
|
file_put_contents($output.'/integrated-report-input.json', json_encode(['path' => $path, 'saved' => $saved], JSON_PRETTY_PRINT | JSON_THROW_ON_ERROR));
|
|
$body = ['version' => $path['case']['version'], 'selection' => ['opportunity' => 'opportunity:'.$offer->id],
|
|
'declarations' => ['project_summary' => 'Deliberate investment-specific summary']];
|
|
$plan = $this->postJson('/investimentos/'.$case.'/contexto/previa', $body)->assertOk()->json();
|
|
$this->assertSame('Deliberate investment-specific summary', $plan['fields']['project_summary']);
|
|
$this->postJson('/investimentos/'.$case.'/contexto/confirmar', [...$body, 'preview_hash' => $plan['preview_hash'], 'operation_key' => (string) Str::uuid(), 'confirm' => true])->assertOk();
|
|
$view = $this->getJson('/investimentos/'.$case.'/contexto')->assertOk()->json();
|
|
$this->postJson('/investimentos/'.$case.'/contexto/previa', ['version' => $view['case']['version'],
|
|
'selection' => ['opportunity' => 'opportunity:'.$offer->id]])->assertOk()->assertJsonPath('fields.project_summary', 'Deliberate investment-specific summary');
|
|
$this->assertSame($payload['description'], $offer->fresh()->description);
|
|
$this->actingAs(User::factory()->create())->getJson('/investimentos/'.$case)->assertForbidden();
|
|
$this->getJson('/api/v1/simulation-drafts/'.$draft.'?context='.$case)->assertNotFound();
|
|
}
|
|
}
|