oportunidades 4
tests / ci (push) Canceled after 0s

This commit is contained in:
2026-10-10 07:03:59 +02:00
parent e662230df7
commit 6ddafc95ba
179 changed files with 22750 additions and 933 deletions
@@ -0,0 +1,88 @@
<?php
namespace Tests\Feature\Auth;
use App\Models\User;
use Illuminate\Contracts\Http\Kernel;
use Illuminate\Encryption\Encrypter;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Session\Middleware\StartSession;
use Laravel\Fortify\Actions\EnableTwoFactorAuthentication;
use Laravel\Fortify\Fortify;
use PragmaRX\Google2FA\Google2FA;
use Tests\TestCase;
class QaAccountSelectionTest extends TestCase
{
use RefreshDatabase;
protected function setUp(): void
{
parent::setUp();
require_once base_path('tools/portal-qa-account-selection.php');
config(['portal.qa_synthetic_accounts_only' => true]);
$kernel = app(Kernel::class);
$kernel->appendMiddlewareToGroup('web', \PortalQaAccountSelection::class);
$kernel->addToMiddlewarePriorityAfter(StartSession::class, \PortalQaAccountSelection::class);
}
private function restored(): User
{
$user = User::factory()->create(['email' => 'restored-source@source.invalid']);
$user->forceFill(['two_factor_secret' => (new Encrypter(random_bytes(32), 'AES-256-CBC'))->encrypt('SYNTHETICONLY'),
'two_factor_confirmed_at' => now()])->save();
return $user;
}
public function test_restored_account_is_stopped_before_mfa_without_changing_its_row(): void
{
$user = $this->restored();
$original = hash('sha256', json_encode($user->fresh()->getRawOriginal(), JSON_THROW_ON_ERROR));
$this->post('/login', ['email' => $user->email, 'password' => 'password'])
->assertRedirect('/login')->assertSessionHasErrors('email')->assertSessionMissing('login.id');
$this->assertGuest();
$this->assertSame($original, hash('sha256', json_encode($user->fresh()->getRawOriginal(), JSON_THROW_ON_ERROR)));
}
public function test_json_restored_login_and_stale_challenge_remain_unauthenticated(): void
{
$user = $this->restored();
$this->postJson('/login', ['email' => $user->email, 'password' => 'password'])
->assertUnprocessable()->assertJsonValidationErrors('email')->assertSessionMissing('login.id');
$this->withSession(['login.id' => $user->id])->get('/two-factor-challenge')
->assertRedirect('/login')->assertSessionHasErrors('email')->assertSessionMissing('login.id');
$this->assertGuest();
}
public function test_synthetic_login_requires_valid_mfa_then_recovers_after_logout(): void
{
$user = User::factory()->create(['email' => 'synthetic-investor@example.invalid']);
app(EnableTwoFactorAuthentication::class)($user);
$user->forceFill(['two_factor_confirmed_at' => now()])->save();
$secret = Fortify::currentEncrypter()->decrypt($user->two_factor_secret);
$credentials = ['email' => $user->email, 'password' => 'password'];
$this->post('/login', $credentials)->assertRedirect('/two-factor-challenge');
$this->assertGuest();
$this->post('/two-factor-challenge', ['code' => 'invalid'])->assertRedirect('/two-factor-challenge');
$this->assertGuest();
$engine = new Google2FA;
$this->post('/two-factor-challenge', ['code' => $engine->getCurrentOtp($secret)])->assertRedirect('/dashboard');
$this->assertAuthenticatedAs($user);
$this->get('/dashboard')->assertOk();
$this->post('/logout')->assertRedirect('/');
$this->assertGuest();
$this->post('/login', $credentials)->assertRedirect('/two-factor-challenge');
// A new login uses a fresh code in Fortify's normal accepted window, not the consumed code.
$this->post('/two-factor-challenge', ['code' => $engine->oathTotp($secret, $engine->getTimestamp() + 1)])->assertRedirect('/dashboard');
$this->assertAuthenticatedAs($user);
}
public function test_qa_account_policy_does_not_apply_when_flag_is_absent(): void
{
config(['portal.qa_synthetic_accounts_only' => false]);
$user = User::factory()->create(['email' => 'ordinary-user@source.invalid']);
$this->post('/login', ['email' => $user->email, 'password' => 'password'])->assertRedirect('/dashboard');
$this->assertAuthenticatedAs($user);
}
}
@@ -0,0 +1,84 @@
<?php
namespace Tests\Feature\Auth;
use App\Models\User;
use Illuminate\Encryption\Encrypter;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Crypt;
use Laravel\Fortify\Actions\EnableTwoFactorAuthentication;
use Laravel\Fortify\Fortify;
use PragmaRX\Google2FA\Google2FA;
use Tests\TestCase;
class TwoFactorDecryptionFailureTest extends TestCase
{
use RefreshDatabase;
private function actor(bool $brokenRecovery = false): User
{
$user = User::factory()->create();
app(EnableTwoFactorAuthentication::class)($user);
$user->forceFill(['two_factor_confirmed_at' => now()])->save();
$foreign = new Encrypter(random_bytes(32), 'AES-256-CBC');
$user->forceFill($brokenRecovery
? ['two_factor_recovery_codes' => $foreign->encrypt('["synthetic-only"]')]
: ['two_factor_secret' => $foreign->encrypt('SYNTHETICQAONLY')])->save();
return $user;
}
public function test_wrong_key_secret_returns_controlled_json_and_cancels_challenge_without_authenticating(): void
{
$user = $this->actor();
$original = $user->getRawOriginal('two_factor_secret');
$this->postJson('/login', ['email' => $user->email, 'password' => 'password'])->assertOk()->assertJson(['two_factor' => true]);
$oldSession = session()->getId();
$this->postJson('/two-factor-challenge', ['code' => '123456'])->assertUnprocessable()->assertJsonValidationErrors('code')
->assertJsonPath('message', 'Não foi possível verificar a autenticação desta conta. Inicie sessão novamente ou contacte o suporte.')
->assertSessionMissing('login.id')->assertSessionMissing('_old_input.code');
$this->assertGuest();
$this->assertNotSame($oldSession, session()->getId());
$this->assertSame($original, $user->fresh()->two_factor_secret);
$this->getJson('/dashboard')->assertUnauthorized();
}
public function test_wrong_key_recovery_codes_are_controlled_and_never_replaced_or_authenticated(): void
{
$user = $this->actor(true);
$original = $user->two_factor_recovery_codes;
$this->postJson('/login', ['email' => $user->email, 'password' => 'password'])->assertOk();
$this->postJson('/two-factor-challenge', ['recovery_code' => 'synthetic-only'])->assertUnprocessable()->assertJsonValidationErrors('recovery_code')
->assertSessionMissing('login.id')->assertSessionMissing('_old_input.recovery_code');
$this->assertGuest();
$this->assertSame($original, $user->fresh()->two_factor_recovery_codes);
}
public function test_browser_failure_redirects_to_login_with_safe_visible_message(): void
{
$user = $this->actor();
$this->post('/login', ['email' => $user->email, 'password' => 'password'])->assertRedirect('/two-factor-challenge');
$this->post('/two-factor-challenge', ['code' => '123456'])->assertRedirect('/login')->assertSessionHasErrors('email')
->assertSessionMissing('login.id')->assertSessionMissing('_old_input.code');
$this->assertGuest();
$this->get('/two-factor-challenge')->assertRedirect('/login');
}
public function test_normal_enrollment_and_valid_mfa_still_authenticate_after_invalid_code_is_rejected(): void
{
$user = User::factory()->create();
app(EnableTwoFactorAuthentication::class)($user);
$user->forceFill(['two_factor_confirmed_at' => now()])->save();
$secret = Fortify::currentEncrypter()->decrypt($user->two_factor_secret);
$this->assertCount(8, $user->recoveryCodes());
$this->assertSame($secret, Crypt::decrypt($user->two_factor_secret));
$this->postJson('/login', ['email' => $user->email, 'password' => 'password'])->assertOk()->assertJson(['two_factor' => true]);
$this->postJson('/two-factor-challenge', ['code' => 'invalid'])->assertUnprocessable();
$this->assertGuest();
$code = (new Google2FA)->getCurrentOtp($secret);
$this->postJson('/two-factor-challenge', ['code' => $code])->assertNoContent();
$this->assertAuthenticatedAs($user);
$this->postJson('/logout')->assertNoContent();
$this->assertGuest();
}
}
@@ -2,11 +2,13 @@
namespace Tests\Feature;
use App\Models\InterestRequest;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Crypt;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Str;
use Inertia\Testing\AssertableInertia as Assert;
use Tests\TestCase;
class BusinessMarketplacePublicTest extends TestCase
@@ -36,6 +38,8 @@ class BusinessMarketplacePublicTest extends TestCase
public function test_interest_is_limited_to_published_business_and_deduplicated_idempotently(): void
{
$this->withoutVite();
config(['portal.notifications_enabled' => false]);
$business = $this->publishedBusiness();
$payload = [
'existingBusinessId' => $business['id'],
@@ -44,12 +48,17 @@ class BusinessMarketplacePublicTest extends TestCase
'email' => 'portal-integration-test@gmail.com',
'phone' => '+258 840000000',
'investorType' => 'empresa',
'country' => 'Moçambique',
'country' => 'PT',
'declared_origin_country_code' => 'ZA',
'sectorInterest' => 'Energia',
'message' => 'Synthetic interest for the published business listing.',
'privacyConsent' => true,
];
$key = (string) Str::uuid();
$this->get('/negocios/'.$business['public_slug'].'/manifestar-interesse')->assertInertia(fn (Assert $p) => $p
->component('investment/business-interest')->has('countries', 249)->where('record.id', $business['id']));
$this->postJson('/api/v1/interest-requests', [...$payload, 'country' => 'ZZ'], ['Idempotency-Key' => (string) Str::uuid()])->assertUnprocessable();
$this->assertDatabaseCount('interest_requests', 0);
$first = $this->postJson('/api/v1/interest-requests', $payload, ['Idempotency-Key' => $key])->assertCreated();
$second = $this->postJson('/api/v1/interest-requests', $payload, ['Idempotency-Key' => $key])->assertCreated();
@@ -65,6 +74,18 @@ class BusinessMarketplacePublicTest extends TestCase
$this->postJson('/api/v1/interest-requests', [...$payload, 'message' => 'Another valid message for this active interest.'])
->assertStatus(409);
$this->assertDatabaseCount('interest_requests', 1);
$interest = InterestRequest::firstOrFail();
$this->assertSame('PT', $interest->country);
$this->assertSame('ZA', $interest->metadata['declared_origin_country_code']);
$manager = User::factory()->create();
config(['portal.managers' => [$manager->email]]);
$this->actingAs($manager)->get('/management/interests/'.$interest->id)->assertInertia(fn (Assert $p) => $p
->where('interest.country', 'PT')->where('declaredOrigin.code', 'ZA'));
$this->get('/management/interests?country=PT&origin=ZA')->assertInertia(fn (Assert $p) => $p
->where('requests.total', 1)->where('originStatistics.interests.0.code', 'ZA')->where('originStatistics.interests.0.count', 1)
->has('originStatistics.businesses', 0));
$this->get('/management/interests?origin=PT')->assertInertia(fn (Assert $p) => $p->where('requests.total', 0)->has('originStatistics.interests', 0));
$this->actingAs(User::factory()->create())->get('/management/interests/'.$interest->id)->assertForbidden();
}
public function test_public_catalogue_filters_by_existing_opportunity_modality_and_opens_response_flow(): void
+1 -1
View File
@@ -261,7 +261,7 @@ class EducationTest extends TestCase
'reviewed_by' => $reviewer->id, 'status' => 'reviewed', 'review_reason' => 'Evidência sintética revista.',
'period_start' => now()->toDateString(), 'period_end' => $data['period_start']]);
$url = '/management/education/demands/'.$demand->id.'/analysis';
$this->get($url)->assertInertia(fn (AssertableInertia $page) => $page->where('eligible', false)->has('candidates', 0));
$this->get($url)->assertOk()->assertInertia(fn (AssertableInertia $page) => $page->where('eligible', false)->has('candidates', 0));
$this->post('/management/education/demands/'.$demand->id.'/review', ['status' => 'reviewed', 'version_no' => 1,
'review_reason' => 'Revisão independente sintética.'])->assertSessionHasNoErrors();
$this->get($url)->assertInertia(fn (AssertableInertia $page) => $page->component('education/analysis')
+11 -2
View File
@@ -127,8 +127,10 @@ class InvestmentPortfolioTest extends TestCase
$this->post('/investidor/guardar/'.$dossier->id)->assertRedirect();
$this->assertDatabaseCount('investor_journeys', 1);
$journey = InvestorJourney::firstOrFail();
$payload = ['message' => 'Pretendo investir neste projecto de teste.', 'country' => 'Moçambique', 'privacy_consent' => true, 'role' => 'investir', 'horizon' => '12 meses'];
$payload = ['message' => 'Pretendo investir neste projecto de teste.', 'country' => 'Moçambique', 'privacy_consent' => true, 'role' => 'investir', 'horizon' => '12 meses', 'declared_origin_country_code' => 'PT'];
$url = '/investidor/'.$journey->id.'/interesse';
$this->postJson($url, [...$payload, 'declared_origin_country_code' => 'ZZ'])->assertUnprocessable();
$this->assertDatabaseCount('interest_requests', 0);
$this->post($url, [...$payload, 'privacy_consent' => false])->assertSessionHasErrors('privacy_consent');
$this->actingAs($other)->post($url, $payload)->assertNotFound();
$this->get('/investidor')->assertInertia(fn (Assert $page) => $page->has('journeys', 0));
@@ -139,8 +141,10 @@ class InvestmentPortfolioTest extends TestCase
$this->assertDatabaseHas('investor_journeys', ['id' => $journey->id, 'status' => 'active']);
$metadata = json_decode(DB::table('interest_requests')->value('metadata'), true);
$this->assertSame($journey->id, $metadata['journey_id']);
$this->assertSame('PT', $metadata['declared_origin_country_code']);
$this->assertSame($dossier->project_id, $metadata['project_id']);
$this->get('/investidor')->assertInertia(fn (Assert $page) => $page->has('journeys', 1)->where('journeys.0.request_status', 'received'));
$this->get('/investidor')->assertInertia(fn (Assert $page) => $page->has('journeys', 1)->where('journeys.0.request_status', 'received')
->where('journeys.0.country', 'Moçambique')->where('journeys.0.declared_origin.code', 'PT'));
$conversion = ['owner_user_id' => $owner->id, 'title' => 'Interesse da jornada', 'next_action' => 'Contactar investidor', 'due_at' => now()->addDay()->toIso8601String()];
$interest = InterestRequest::firstOrFail();
try {
@@ -152,6 +156,11 @@ class InvestmentPortfolioTest extends TestCase
$lead = app(ConvertInterestToLead::class)->convert($interest, [...$conversion, 'investor_id' => $journey->investor_id], $owner->id);
$this->assertSame($journey->investor_id, $lead->investor_id);
$this->assertDatabaseCount('investors', 1);
config(['portal.managers' => [$owner->email]]);
$this->get('/management/interests?origin=PT')->assertInertia(fn (Assert $page) => $page
->where('requests.total', 1)->where('requests.data.0.declaredOrigin.code', 'PT')
->where('originStatistics.interests.0.code', 'PT')->where('originStatistics.interests.0.count', 1));
$this->get('/management/interests?origin=MZ')->assertInertia(fn (Assert $page) => $page->where('requests.total', 0)->has('originStatistics.interests', 0));
}
public function test_private_manifestation_link_requires_authentication_and_preserves_the_public_dossier(): void
+12
View File
@@ -198,9 +198,21 @@ class InvestorGuidanceTest extends TestCase
$this->assertArrayNotHasKey('market_evidence', $owner['facts']);
$this->assertStringNotContainsString('PRIVATE PERSONAL', json_encode($owner));
$this->assertSame(1, $owner['consulted'][0]['version']);
$draftUrl = '/simulador-fiscal?draft='.$draft->id;
$this->assertSame($draftUrl, $owner['actions']['simulator']);
$this->assertSame($draftUrl, $owner['actions']['plan']);
$finance = collect($owner['phases'])->firstWhere('id', 'finance');
$this->assertSame($draftUrl, $finance['recommendations'][0]['href']);
$otherDraft = $draft->replicate();
$otherDraft->id = (string) Str::uuid();
$otherDraft->economic_snapshot = SimulationFormDraft::encryptSnapshot(['project_summary' => 'Outro projecto, não reutilizar']);
$otherDraft->save();
$this->getJson('/api/v1/simulation-drafts/'.$draft->id)->assertOk()->assertJsonPath('data.id', $draft->id)->assertJsonPath('data.economic_snapshot.project_summary', 'Ideia guardada');
$manager = $this->actingAs($this->manager)->getJson('/balcao-virtual/dossiers/'.$id.'/orientacao')->assertOk()->json();
$this->assertNull($manager['financial_draft']);
$this->assertSame([], $manager['consulted']);
$this->assertSame('/simulador-fiscal', $manager['actions']['simulator']);
$this->getJson('/api/v1/simulation-drafts/'.$draft->id)->assertNotFound();
$this->actingAs($this->investor)->postJson('/balcao-virtual/dossiers', ['title' => 'Reutilização', 'simulation_id' => $draft->id, 'client_key' => (string) Str::uuid(), 'reuse_consent' => true])->assertJsonPath('id', $id);
$this->assertDatabaseCount('desk_cases', 1);
$this->actingAs(User::factory()->create())->getJson('/balcao-virtual/dossiers/'.$id.'/orientacao')->assertForbidden();
+144
View File
@@ -0,0 +1,144 @@
<?php
namespace Tests\Feature;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Str;
use Inertia\Testing\AssertableInertia as Assert;
use Tests\TestCase;
class InvestorPathTest extends TestCase
{
use RefreshDatabase;
private User $owner;
private User $manager;
protected function setUp(): void
{
parent::setUp();
$this->withoutVite();
$this->owner = User::factory()->create();
$this->manager = User::factory()->create();
config(['investor-path.enabled' => true, 'portal.managers' => [$this->manager->email], 'suitecrm.enabled' => false, 'investmoz_ai.enabled' => false]);
$identity = DB::selectOne('select current_database() as name, inet_server_port() as port');
$this->assertStringEndsWith('_test', $identity->name);
$this->assertSame(55441, (int) $identity->port);
Http::fake();
}
private function context(string $title = 'Ideia sintética QA'): string
{
return $this->actingAs($this->owner)->postJson('/investimentos', ['title' => $title, 'client_key' => (string) Str::uuid(), 'reuse_consent' => true])->assertCreated()->json('id');
}
private function pathView(string $id): array
{
return $this->actingAs($this->owner)->getJson('/investimentos/'.$id)->assertOk()->json();
}
private function snapshot(string $title): array
{
return ['personal_snapshot' => [], 'economic_snapshot' => ['purpose' => 'simulate_only', 'project_title' => $title, 'project_summary' => 'Descrição guardada do investimento '.$title,
'financial_tools_snapshot' => json_encode(['version' => 1, 'data' => ['budgetPeriod' => 'Mensal',
'loan' => ['principal' => '100000', 'rate' => '0', 'months' => '24', 'rateType' => 'nominal', 'periodMonths' => '1', 'gracePeriods' => '0', 'upfrontFee' => '0', 'recurringFee' => '0'],
'breakEven' => ['fixed' => '1000', 'price' => '100', 'variable' => '50'], 'funding' => ['total' => '100000', 'secured' => '30000'],
'incomes' => [['label' => 'Receitas QA', 'amount' => '100']], 'expenses' => [['label' => 'Custos QA', 'amount' => '50']]]])]];
}
public function test_two_independent_investments_recover_tool_evidence_support_and_internal_tasks_without_external_calls(): void
{
$service = $this->actingAs($this->manager)->postJson('/management/balcao/servicos', ['code' => 'project-preparation', 'confirm' => true])->assertCreated()->json('id');
$ids = [$this->context('A sem terra nem financiamento externo'), $this->context('B com terra e financiamento externo')];
$draftIds = [];
foreach ($ids as $index => $id) {
$view = $this->pathView($id);
$this->assertCount(19, $view['steps']);
$this->assertCount(5, $view['phases']);
$this->postJson('/investimentos/'.$id.'/aplicabilidade', ['version' => $view['case']['version'], 'land_needed' => $index ? 'yes' : 'no', 'external_funding' => $index ? 'yes' : 'no', 'viability' => 'proceed', 'reason' => 'Cenário sintético declarado pelo promotor'])->assertOk();
$data = [...$this->snapshot($index ? 'B' : 'A'), 'context' => $id];
$key = (string) Str::uuid();
$created = $this->postJson('/api/v1/simulation-drafts', $data, ['Idempotency-Key' => $key])->assertCreated();
$draft = $created->json('data.id');
$draftIds[] = $draft;
$this->postJson('/api/v1/simulation-drafts', $data, ['Idempotency-Key' => $key])->assertOk()->assertJsonPath('data.replayed', true);
$this->postJson('/api/v1/simulation-drafts', $data, ['Idempotency-Key' => (string) Str::uuid()])->assertConflict();
$this->getJson('/api/v1/simulation-drafts/current?context='.$id)->assertJsonPath('data.id', $draft)->assertJsonPath('data.economic_snapshot.project_title', $index ? 'B' : 'A');
$returned = $this->postJson('/investimentos/'.$id.'/resultados', ['version' => $this->pathView($id)['case']['version'], 'draft_id' => $draft, 'draft_version' => 1])->assertOk()->json('result');
$this->postJson('/investimentos/'.$id.'/resultados', ['version' => 1, 'draft_id' => $draft, 'draft_version' => 1])->assertOk()->assertJsonPath('result.id', $returned['id']);
$support = $this->postJson('/balcao-virtual/dossiers/'.$id.'/pedidos', ['service_version_id' => $service, 'client_key' => (string) Str::uuid()])->assertCreated()->json('id');
$this->getJson('/balcao-virtual/pedidos/'.$support)->assertJsonPath('request.status', 'draft')->assertJsonPath('request.payload.description', $data['economic_snapshot']['project_summary']);
$v = $this->pathView($id);
$this->assertSame($index ? 'applicable' : 'not_needed_for_preparation', collect($v['steps'])->firstWhere('code', 'S10')['applicability']);
$this->postJson('/investimentos/'.$id.'/etapas/S10', ['version' => $v['case']['version'], 'state' => 'waiting_external', 'action' => 'prepare', 'reason' => 'Aguarda localização e informação do terreno'])->assertStatus($index ? 200 : 422);
$task = $this->postJson('/investimentos/'.$id.'/tarefas', ['version' => $this->pathView($id)['case']['version'], 'code' => 'S02', 'title' => 'Análise de mercado independente', 'due_at' => now()->addWeek()->toIso8601String(), 'consent' => true])->assertCreated()->json('id');
$this->actingAs($this->manager)->postJson('/balcao-virtual/dossiers/'.$id.'/tarefas/'.$task, ['version' => 1, 'status' => 'in_progress', 'assignee_user_id' => $this->manager->id, 'reason' => 'Acompanhamento interno atribuído'])->assertOk();
$this->postJson('/balcao-virtual/dossiers/'.$id.'/tarefas/'.$task, ['version' => 2, 'status' => 'done', 'reason' => 'Evidência de mercado preparada enquanto a terra aguarda'])->assertOk();
$this->getJson('/investimentos/'.$id)->assertJsonPath('results', [])->assertJsonPath('tasks.0.status', 'done')->assertJsonPath('canWrite', false)->assertJsonPath('canReview', true);
$this->getJson('/balcao-virtual/dossiers/'.$id)->assertJsonMissingPath('case.payload.investor_path');
// A fresh HTTP session and a newly resolved account recover only this context's data.
$this->flushSession();
$this->actingAs(User::findOrFail($this->owner->id))->getJson('/investimentos/'.$id)->assertJsonPath('results.0.id', $returned['id'])->assertJsonPath('results.0.draft_id', $draft)->assertJsonPath('tasks.0.status', 'done');
$this->get('/investimentos/'.$id)->assertInertia(fn (Assert $page) => $page->component('investor-path/show')->has('steps', 19)->has('phases', 5)->where('case.id', $id));
}
$this->assertNotSame($draftIds[0], $draftIds[1]);
$this->actingAs($this->owner)->getJson('/api/v1/simulation-drafts/'.$draftIds[0].'?context='.$ids[1])->assertForbidden();
$this->putJson('/api/v1/simulation-drafts/'.$draftIds[0], [...$this->snapshot('Outra ideia'), 'context' => $ids[1]], ['If-Match' => '"sfd-'.$draftIds[0].'-v1"'])->assertForbidden();
$this->postJson('/investimentos/'.$ids[1].'/resultados', ['version' => $this->pathView($ids[1])['case']['version'], 'draft_id' => $draftIds[0], 'draft_version' => 1])->assertForbidden();
$this->actingAs($this->manager)->getJson('/api/v1/simulation-drafts/'.$draftIds[0])->assertNotFound();
$this->actingAs($this->owner)->getJson('/api/v1/simulation-drafts/current')->assertJsonPath('data', null);
$this->assertDatabaseCount('simulation_form_drafts', 2);
$this->assertDatabaseCount('journey_steps', 38);
$this->assertDatabaseCount('desk_requests', 2);
$this->assertDatabaseCount('tasks', 2);
Http::assertNothingSent();
}
public function test_permissions_stale_versions_and_real_dependencies_do_not_block_independent_preparation(): void
{
$id = $this->context();
$v = $this->pathView($id);
$this->assertSame('undetermined', collect($v['steps'])->firstWhere('code', 'S10')['applicability']);
$payload = ['version' => $v['case']['version'], 'state' => 'preparing', 'action' => 'prepare', 'reason' => 'Preparação independente'];
$this->postJson('/investimentos/'.$id.'/etapas/S18', $payload)->assertOk();
$this->postJson('/investimentos/'.$id.'/etapas/S02', $payload)->assertConflict();
$payload['version'] = $this->pathView($id)['case']['version'];
$this->postJson('/investimentos/'.$id.'/etapas/S18', [...$payload, 'action' => 'operate'])->assertUnprocessable();
$this->postJson('/investimentos/'.$id.'/etapas/S18', [...$payload, 'state' => 'completed'])->assertUnprocessable();
$this->postJson('/investimentos/'.$id.'/etapas/S02', [...$payload, 'state' => 'approved', 'action' => 'record_external'])->assertForbidden();
$this->actingAs($this->manager)->postJson('/investimentos/'.$id.'/etapas/S02', [...$payload, 'state' => 'approved', 'action' => 'record_external'])->assertUnprocessable();
$stranger = User::factory()->create();
$this->actingAs($stranger)->getJson('/investimentos/'.$id)->assertForbidden();
$this->postJson('/api/v1/simulation-drafts', [...$this->snapshot('Invasão'), 'context' => $id], ['Idempotency-Key' => (string) Str::uuid()])->assertForbidden();
$this->postJson('/investimentos/'.$id.'/etapas/S02', $payload)->assertForbidden();
$this->assertDatabaseCount('simulation_form_drafts', 0);
$this->assertDatabaseCount('journey_steps', 19);
$this->actingAs($this->owner)->get('/dashboard')->assertInertia(fn (Assert $page) => $page->where('summary', null)->where('investorPath.contexts.0.id', $id));
$this->actingAs($this->manager)->get('/dashboard')->assertInertia(fn (Assert $page) => $page->has('summary')->missing('investorPath'));
}
public function test_applicability_changes_preserve_old_branch_evidence_and_legacy_workflow(): void
{
$id = $this->context();
$workflow = DB::table('desk_cases')->where('id', $id)->value('workflow');
$facts = ['land_needed' => 'yes', 'external_funding' => 'unknown', 'viability' => 'reformulate', 'reason' => 'Preparação provisória'];
$this->postJson('/investimentos/'.$id.'/aplicabilidade', ['version' => $this->pathView($id)['case']['version'], ...$facts])->assertOk();
$this->postJson('/investimentos/'.$id.'/etapas/S10', ['version' => $this->pathView($id)['case']['version'], 'state' => 'preparing', 'action' => 'prepare', 'reason' => 'Evidência anterior do terreno'])->assertOk();
$this->postJson('/investimentos/'.$id.'/aplicabilidade', ['version' => $this->pathView($id)['case']['version'], ...$facts, 'land_needed' => 'no'])->assertOk();
$step = collect($this->pathView($id)['steps'])->firstWhere('code', 'S10');
$this->assertSame('dispensed', $step['state']);
$this->assertSame('preparing', $step['saved_state']);
$this->assertSame('Evidência anterior do terreno', $step['detail']['reason']);
$this->postJson('/investimentos/'.$id.'/aplicabilidade', ['version' => $this->pathView($id)['case']['version'], ...$facts])->assertOk();
$this->assertSame('preparing', collect($this->pathView($id)['steps'])->firstWhere('code', 'S10')['state']);
$this->assertSame($workflow, DB::table('desk_cases')->where('id', $id)->value('workflow'));
config(['investor-path.enabled' => false]);
$this->getJson('/investimentos')->assertNotFound();
$this->getJson('/investimentos/'.$id)->assertNotFound();
$this->getJson('/balcao-virtual/dossiers/'.$id)->assertOk();
}
}
@@ -0,0 +1,132 @@
<?php
namespace Tests\Feature;
use App\Jobs\ScanDocumentForMalware;
use App\Models\Opportunity;
use App\Models\User;
use App\Services\Malware\MalwareScanner;
use App\Services\OpportunityManagement;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Http\UploadedFile;
use Illuminate\Support\Facades\Crypt;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Queue;
use Illuminate\Support\Facades\Storage;
use Illuminate\Support\Facades\URL;
use Illuminate\Support\Str;
use Tests\TestCase;
class OpportunityRealDocumentOperationsTest extends TestCase
{
use RefreshDatabase;
private function setupDraft(): Opportunity
{
Storage::fake('local');
Queue::fake(); // Inspect quarantine first; execute the real job explicitly below.
config(['services.malware.scanner' => 'clamd', 'services.malware.host' => '127.0.0.1', 'services.malware.port' => 3310]);
if (! app(MalwareScanner::class)->health()['available']) {
$this->markTestSkipped('Real ClamD required; operational evidence remains pending.');
}
$user = User::factory()->create();
config(['portal.managers' => [$user->email]]);
$id = $this->actingAs($user)->postJson('/management/opportunities/drafts', ['creation_key' => (string) Str::uuid(), 'reason' => 'Operações documentais reais em QA isolada'])->assertCreated()->json('id');
return Opportunity::findOrFail($id);
}
private function input(Opportunity $o, array $payload): array
{
return ['etag' => app(OpportunityManagement::class)->etag($o->fresh()), 'creation_key' => (string) Str::uuid(), 'payload' => $payload, 'reason' => 'Operação e recuperação documental em QA isolada'];
}
private function scan(string $id): void
{
(new ScanDocumentForMalware($id))->handle(app(MalwareScanner::class));
$d = DB::table('documents')->where('id', $id)->first();
$this->assertSame('clean', $d->malware_scan_status);
$this->assertSame('ClamAV', $d->malware_engine);
$this->assertNotSame('unknown', $d->malware_engine_version);
$this->assertSame(trim($d->sha256), $d->malware_scanned_sha256);
$this->assertNotNull($d->malware_signatures_at);
$this->assertDatabaseHas('document_malware_scans', ['document_id' => $id, 'result' => 'clean', 'engine' => 'ClamAV']);
}
public function test_real_original_replacement_quarantine_retry_download_delete_restore_and_authorization(): void
{
$o = $this->setupDraft();
$bytes = "%PDF-1.7\nSynthetic QA only\n%%EOF";
$url = '/management/opportunities/'.$o->id.'/resources/documents';
$r = $this->post($url, [...$this->input($o, ['title' => 'Synthetic QA document']), 'file' => UploadedFile::fake()->createWithContent('qa.pdf', $bytes)], ['Accept' => 'application/json'])->assertCreated()->json();
$doc = DB::table('opportunity_documents')->where('id', $r['id'])->value('document_id');
$download = $url.'/'.$r['id'].'/download';
$this->get($download)->assertStatus(423);
config(['services.malware.port' => 1]);
(new ScanDocumentForMalware($doc))->handle(app(MalwareScanner::class));
$this->assertDatabaseHas('documents', ['id' => $doc, 'status' => 'quarantined', 'malware_scan_status' => 'inconclusive']);
$this->get($download)->assertStatus(423);
config(['services.malware.port' => 3310]);
$this->scan($doc);
$this->get($download)->assertOk()->assertContent($bytes);
$old = DB::table('documents')->where('id', $doc)->first();
$this->assertNotSame($bytes, Storage::disk('local')->get($old->storage_key));
$newBytes = "%PDF-1.7\nSecond synthetic QA edition\n%%EOF";
$replace = [...$this->input($o, ['title' => 'Synthetic second edition', 'edition' => '2']), 'version' => 1, 'file' => UploadedFile::fake()->createWithContent('qa-v2.pdf', $newBytes)];
$this->post($url.'/'.$r['id'], $replace, ['Accept' => 'application/json'])->assertOk();
$this->post($url.'/'.$r['id'], $replace, ['Accept' => 'application/json'])->assertConflict();
$next = DB::table('opportunity_documents')->where('id', $r['id'])->value('document_id');
$this->assertNotSame($doc, $next);
$this->assertSame($bytes, Crypt::decryptString(Storage::disk('local')->get($old->storage_key)));
$this->get($download)->assertStatus(423);
$this->scan($next);
$this->get($download)->assertContent($newBytes);
$life = ['etag' => app(OpportunityManagement::class)->etag($o->fresh()), 'version' => 2, 'action' => 'delete', 'confirm' => true, 'reason' => 'Exclusão lógica do recurso apenas em QA'];
$this->postJson($url.'/'.$r['id'].'/lifecycle', $life)->assertOk();
$this->get($download)->assertStatus(423);
$this->postJson($url.'/'.$r['id'].'/lifecycle', [...$life, 'etag' => app(OpportunityManagement::class)->etag($o->fresh()), 'version' => 3, 'action' => 'restore'])->assertOk();
$this->get($download)->assertContent($newBytes);
$this->assertDatabaseHas('documents', ['id' => $doc, 'deleted_at' => null]);
$this->actingAs(User::factory()->create())->get($download)->assertForbidden();
}
public function test_three_derivatives_have_independent_real_scans_signed_previews_and_integrity_checks(): void
{
$o = $this->setupDraft();
$this->assertTrue(function_exists('imagewebp'), 'GD/WebP required; rejection does not demonstrate derivatives.');
$image = imagecreatetruecolor(1600, 1600);
ob_start();
imagepng($image);
$bytes = ob_get_clean();
imagedestroy($image);
$base = '/management/opportunities/'.$o->id.'/resources/images';
$r = $this->post($base, [...$this->input($o, ['title' => 'Synthetic QA image', 'alt_text' => 'Synthetic square for QA only', 'caption' => 'Synthetic image no active data', 'rights_status' => 'owned']), 'file' => UploadedFile::fake()->createWithContent('qa.png', $bytes)], ['Accept' => 'application/json'])->assertCreated()->json();
$doc = DB::table('opportunity_images')->where('id', $r['id'])->value('document_id');
$this->postJson($base.'/'.$r['id'].'/prepare', ['etag' => app(OpportunityManagement::class)->etag($o->fresh()), 'version' => 1, 'reason' => 'Preparar derivadas sintéticas em QA'])->assertStatus(423);
$this->scan($doc);
$input = ['etag' => app(OpportunityManagement::class)->etag($o->fresh()), 'version' => 1, 'reason' => 'Preparar derivadas sintéticas em QA'];
$this->postJson($base.'/'.$r['id'].'/prepare', $input)->assertOk();
$this->postJson($base.'/'.$r['id'].'/prepare', $input)->assertConflict();
$p = json_decode(DB::table('opportunity_images')->where('id', $r['id'])->value('payload'), true);
$this->assertCount(3, $p['derivatives']);
$this->assertSame([320, 640, 1280], array_column($p['derivatives'], 'width'));
foreach ($p['derivatives'] as $derived) {
$id = $derived['document_id'];
$url = URL::temporarySignedRoute('management.opportunity-image-preview', now()->addMinutes(10), ['id' => $o->id, 'resourceId' => $r['id'], 'documentId' => $id]);
$this->get($url)->assertStatus(423);
$this->scan($id);
$d = DB::table('documents')->where('id', $id)->first();
$webp = Crypt::decryptString(Storage::disk('local')->get($d->storage_key));
$this->assertSame('image/webp', getimagesizefromstring($webp)['mime']);
$this->assertStringNotContainsString('EXIF', $webp);
$this->get($url)->assertOk()->assertHeader('Content-Type', 'image/webp')->assertContent($webp);
$this->get(explode('?', $url)[0])->assertForbidden();
Storage::disk('local')->put($d->storage_key, Crypt::encryptString($webp.'tampered QA'));
$this->get($url)->assertConflict();
Storage::disk('local')->put($d->storage_key, Crypt::encryptString($webp));
}
$this->assertSame(4, DB::table('document_malware_scans')->where('result', 'clean')->count());
$this->get('/management/opportunities/'.$o->id.'/resources')->assertOk();
$this->actingAs(User::factory()->create())->get($url)->assertForbidden();
}
}
+1 -1
View File
@@ -125,7 +125,7 @@ class OpportunityResourcesTest extends TestCase
$this->assertSame('image/webp', getimagesizefromstring($webp)['mime']);
(new ScanDocumentForMalware($document->id))->handle(new CleanScannerFixture);
}
$preview = URL::temporarySignedRoute('opportunity-image-preview', now()->addMinutes(10), ['id' => $o->id, 'resourceId' => $image->id, 'documentId' => $payload['derivatives'][0]['document_id']]);
$preview = URL::temporarySignedRoute('management.opportunity-image-preview', now()->addMinutes(10), ['id' => $o->id, 'resourceId' => $image->id, 'documentId' => $payload['derivatives'][0]['document_id']]);
$this->get($preview)->assertOk()->assertHeader('Content-Type', 'image/webp');
$this->actingAs(User::factory()->create())->get($preview)->assertForbidden();
}
@@ -0,0 +1,112 @@
<?php
namespace Tests\Feature;
use App\Models\User;
use App\Services\VirtualDesk;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Crypt;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Str;
use Inertia\Testing\AssertableInertia as Assert;
use Tests\TestCase;
class PortalAuditConsumerLotTest extends TestCase
{
use RefreshDatabase;
protected function setUp(): void
{
parent::setUp();
$this->withoutVite();
Http::fake();
config(['suitecrm.enabled' => false, 'investmoz_ai.enabled' => false, 'crm.notifications.enabled' => false]);
$this->assertSame(55441, (int) DB::selectOne('SELECT inet_server_port() AS port')->port);
}
private function manager(): User
{
$actor = User::factory()->create();
$actor->forceFill(['two_factor_secret' => Crypt::encryptString('QA-ONLY'), 'two_factor_confirmed_at' => now()])->save();
config(['portal.managers' => [$actor->email]]);
return $actor;
}
private function location(string $type, ?string $parent = null): string
{
$id = (string) Str::uuid();
DB::table('geo_locations')->insert(['id' => $id, 'name' => 'Synthetic '.$type, 'code' => $id, 'location_type' => $type, 'parent_id' => $parent,
'source_system' => 'cartografia-workbook', 'external_id' => '964a187ad32ea553b9e112cd159caa90b900050c42b7329594a75bcd798a96ff:'.$id, 'data_classification' => 'public']);
return $id;
}
public function test_private_official_context_is_authorized_and_does_not_become_a_public_investment_site(): void
{
$manager = $this->manager();
$province = $this->location('province', $this->location('country'));
$district = $this->location('district', $province);
$post = $this->location('admin_post', $district);
$locality = $this->location('locality', $post);
$id = (string) Str::uuid();
$properties = json_encode(['purpose' => 'administrative_boundary', 'authority' => 'Synthetic official fixture, QA only', 'source_version' => '2019-06-07', 'legal_currentness' => 'not_determined', 'source_sha256' => str_repeat('a', 64)]);
DB::insert("INSERT INTO geo_geometries(id,geo_location_id,geometry_type,geom,properties,data_classification) VALUES (?,?,'polygon',ST_GeomFromText('POLYGON((32 -20,33 -20,33 -19,32 -19,32 -20))',4326),?::jsonb,'internal')", [$id, $post, $properties]);
$url = '/api/v1/investment/territory-geometries?territory_id='.$locality;
$this->getJson($url)->assertOk()->assertJsonPath('territory_registered', true)->assertJsonPath('selected_geometry_available', false)->assertJsonCount(0, 'areas');
$private = '/management/territory-geometries?territory_id='.$locality;
$this->actingAs(User::factory()->create())->getJson($private)->assertForbidden();
$this->actingAs($manager)->getJson($private)->assertOk()->assertJsonPath('selected_geometry_available', false)->assertJsonPath('context_geometry_count', 1)
->assertJsonPath('areas.0.territory_id', $post)->assertJsonPath('areas.0.source_date', '2019-06-07')->assertJsonPath('areas.0.geometry.type', 'Polygon')->assertJsonPath('investment_site', false);
$this->getJson('/management/territory-geometries?territory_id='.$post)->assertJsonPath('selected_geometry_available', true);
$this->getJson('/management/territory-geometries?territory_id=bad')->assertUnprocessable();
DB::table('geo_geometries')->where('id', $id)->update(['data_classification' => 'public']); // explicit synthetic QA permission, never production
$this->getJson($url)->assertJsonPath('context_geometry_count', 1)->assertJsonPath('selected_geometry_available', false);
DB::table('geo_geometries')->where('id', $id)->update(['deleted_at' => now()]);
$this->getJson($private)->assertJsonCount(0, 'areas');
$this->assertDatabaseCount('opportunity_locations', 0);
$this->assertDatabaseCount('investment_zone_geometries', 0);
}
public function test_all_three_desk_forms_save_recover_countries_and_support_authorized_corrections(): void
{
$manager = $this->manager();
$owner = User::factory()->create();
foreach (['project-preparation', 'project-change', 'project-follow-up'] as $index => $code) {
$service = $this->actingAs($manager)->postJson('/management/balcao/servicos', ['code' => $code, 'confirm' => true])->assertCreated()->json('id');
$case = $this->actingAs($owner)->postJson('/balcao-virtual/dossiers', ['title' => 'Synthetic QA '.$code, 'client_key' => (string) Str::uuid(), 'reuse_consent' => true])->assertCreated()->json('id');
$id = $this->postJson('/balcao-virtual/dossiers/'.$case.'/pedidos', ['service_version_id' => $service, 'client_key' => (string) Str::uuid()])->assertCreated()->json('id');
$url = '/balcao-virtual/pedidos/'.$id;
$payload = ['title' => 'Synthetic QA', 'description' => 'Synthetic description', 'country' => 'PT', 'declared_origin_country_code' => 'ZA'];
if ($code === 'project-preparation') {
$payload += ['investment_amount' => '10.00', 'currency' => 'MZN'];
}
if ($code === 'project-change') {
$payload += ['original_reference' => 'QA-only', 'change_reason' => 'Synthetic change'];
}
if ($code === 'project-follow-up') {
$payload += ['original_reference' => 'QA-only', 'progress' => 'Synthetic progress'];
}
$this->putJson($url, ['version' => 1, 'payload' => [...$payload, 'country' => 'ZZ']])->assertUnprocessable()->assertJsonValidationErrors('payload.country');
$this->putJson($url, ['version' => 1, 'payload' => $payload])->assertOk()->assertJsonPath('version', 2);
$this->get($url)->assertInertia(fn (Assert $p) => $p->has('countries', 249)->where('request.payload.country', 'PT')->where('request.payload.declared_origin_country_code', 'ZA'));
$cipher = DB::table('desk_requests')->where('id', $id)->value('payload');
$this->assertSame('PT', app(VirtualDesk::class)->decode($cipher)['country']);
$this->putJson($url, ['version' => 2, 'payload' => $payload, 'submit' => true, 'consent' => true])->assertOk()->assertJsonPath('status', 'submitted');
$this->postJson($url.'/estado', ['version' => 3, 'status' => 'reviewing', 'reason' => 'Synthetic self review'])->assertForbidden();
$this->actingAs($manager)->postJson($url.'/estado', ['version' => 3, 'status' => 'returned', 'reason' => 'Synthetic authorized correction', 'editable_fields' => ['country', 'declared_origin_country_code']])->assertOk()->assertJsonPath('version', 4);
$this->actingAs($owner)->putJson($url, ['version' => 4, 'payload' => [...$payload, 'title' => 'Unauthorized change']])->assertUnprocessable();
$this->putJson($url, ['version' => 4, 'payload' => [...$payload, 'country' => 'MZ', 'declared_origin_country_code' => 'PT']])->assertOk()->assertJsonPath('version', 5);
$this->getJson($url)->assertJsonPath('request.payload.country', 'MZ')->assertJsonPath('request.payload.declared_origin_country_code', 'PT');
$this->putJson($url, ['version' => 4, 'payload' => $payload])->assertConflict();
$this->actingAs(User::factory()->create())->getJson($url)->assertForbidden();
$this->assertDatabaseHas('desk_requests', ['id' => $id, 'version' => 5, 'status' => 'returned']);
$this->actingAs($manager)->get('/management/interests?origin=PT')->assertInertia(fn (Assert $p) => $p
->where('originStatistics.desk.0.code', 'PT')->where('originStatistics.desk.0.count', $index + 1)
->has('originStatistics.interests', 0)->has('originStatistics.businesses', 0));
$this->get('/management/interests?origin=ZA')->assertInertia(fn (Assert $p) => $p->has('originStatistics.desk', 0));
}
Http::assertNothingSent();
}
}
@@ -0,0 +1,178 @@
<?php
namespace Tests\Feature;
use App\Models\InterestRequest;
use App\Models\SimulationFormDraft;
use App\Models\User;
use App\Services\CountryCatalogue;
use App\Services\VirtualDesk;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Str;
use Inertia\Testing\AssertableInertia as Assert;
use Tests\TestCase;
class PortalAuditContinuationTest extends TestCase
{
use RefreshDatabase;
protected function setUp(): void
{
parent::setUp();
$this->withoutVite();
$identity = DB::selectOne('SELECT current_database() AS database,inet_server_port() AS port');
$this->assertStringEndsWith('_test', $identity->database);
$this->assertSame(55441, (int) $identity->port);
config(['portal.notifications_enabled' => false, 'investmoz_ai.enabled' => false, 'suitecrm.enabled' => false]);
Http::fake();
}
private function publicPayload(): array
{
return ['fullName' => 'Pessoa sintética QA', 'email' => 'portal-integration-test@gmail.com', 'investorType' => 'individual',
'country' => 'PT', 'declared_origin_country_code' => 'ZA', 'sectorInterest' => 'Energia',
'message' => 'Pedido exclusivamente sintético na base QA.', 'privacyConsent' => true];
}
public function test_public_country_and_origin_survive_api_storage_management_recovery_and_statistics(): void
{
foreach (['/', '/manifestar-interesse', '/apoio', '/formularios/investidor', '/formularios/empresa'] as $url) {
$this->get($url)->assertInertia(fn (Assert $page) => $page->has('countries', 249));
}
$data = $this->publicPayload();
$this->postJson('/api/v1/interest-requests', [...$data, 'country' => 'ZZ'], ['Idempotency-Key' => (string) Str::uuid()])->assertUnprocessable()->assertJsonValidationErrors('country');
$this->assertDatabaseCount('interest_requests', 0);
$key = ['Idempotency-Key' => (string) Str::uuid()];
$receipt = $this->postJson('/api/v1/interest-requests', $data, $key)->assertCreated()->json();
$this->postJson('/api/v1/interest-requests', $data, $key)->assertExactJson($receipt);
$interest = InterestRequest::firstOrFail();
$this->assertSame('PT', $interest->country);
$this->assertSame('ZA', $interest->metadata['declared_origin_country_code']);
$manager = User::factory()->create();
config(['portal.managers' => [$manager->email]]);
$this->actingAs($manager)->get('/management/interests/'.$interest->id)->assertInertia(fn (Assert $page) => $page
->component('management/interest')->where('interest.country', 'PT')->where('declaredOrigin.code', 'ZA')->where('declaredOrigin.name', app(CountryCatalogue::class)->describe('ZA')['name']));
$this->get('/management/crm/leads?origin=ZA')->assertInertia(fn (Assert $page) => $page
->where('originStatistics.interests.0.code', 'ZA')->where('originStatistics.interests.0.count', 1));
$this->get('/management/crm/leads?origin=PT')->assertInertia(fn (Assert $page) => $page->has('originStatistics.interests', 0));
$this->actingAs(User::factory()->create())->get('/management/interests/'.$interest->id)->assertForbidden();
$this->assertDatabaseCount('interest_requests', 1);
Http::assertNothingSent();
}
public function test_support_kinds_keep_iso_country_separate_and_legacy_text_unchanged(): void
{
$data = $this->publicPayload();
$manager = User::factory()->create();
config(['portal.managers' => [$manager->email]]);
foreach (['support', 'investor', 'company', 'partnership'] as $index => $kind) {
$payload = [...$data, 'kind' => $kind, 'company' => 'Empresa sintética QA', 'submission_key' => (string) Str::uuid()];
$this->post('/apoio', $payload)->assertSessionHasNoErrors()->assertRedirect();
$this->assertSame('PT', InterestRequest::latest('id')->firstOrFail()->country);
$this->assertSame('ZA', InterestRequest::latest('id')->firstOrFail()->metadata['declared_origin_country_code']);
$interest = InterestRequest::latest('id')->firstOrFail();
$this->actingAs($manager)->get('/management/interests/'.$interest->id)->assertInertia(fn (Assert $p) => $p
->where('interest.country', 'PT')->where('declaredOrigin.code', 'ZA'));
$this->get('/management/interests?country=PT&origin=ZA')->assertInertia(fn (Assert $p) => $p
->where('requests.total', $index + 1)->where('originStatistics.interests.0.code', 'ZA')
->where('originStatistics.interests.0.count', $index + 1));
$this->get('/management/interests?origin=PT')->assertInertia(fn (Assert $p) => $p->where('requests.total', 0)->has('originStatistics.interests', 0));
}
$this->postJson('/apoio', [...$data, 'kind' => 'support', 'country' => 'ZZ', 'submission_key' => (string) Str::uuid()])->assertUnprocessable()->assertJsonValidationErrors('country');
$this->post('/apoio', [...$data, 'kind' => 'support', 'country' => 'Moçambique', 'submission_key' => (string) Str::uuid()])->assertSessionHasNoErrors();
$this->assertSame('Moçambique', InterestRequest::latest('id')->firstOrFail()->country);
$this->assertDatabaseCount('interest_requests', 5);
}
private function territory(string $type, ?string $parent = null, bool $public = true): string
{
$id = (string) Str::uuid();
DB::table('geo_locations')->insert(['id' => $id, 'code' => $id, 'name' => 'Território sintético QA '.$type, 'location_type' => $type,
'parent_id' => $parent, 'source_system' => 'cartografia-workbook', 'external_id' => '964a187ad32ea553b9e112cd159caa90b900050c42b7329594a75bcd798a96ff:'.$id,
'data_classification' => $public ? 'public' : 'internal']);
return $id;
}
public function test_iso_catalogue_does_not_depend_on_geo_publication_and_is_searchable_and_pageable(): void
{
$this->assertDatabaseCount('geo_locations', 0);
$this->assertDatabaseCount('reference_catalog_versions', 0);
$codes = [];
foreach ([1, 2, 3] as $page) {
$rows = $this->getJson('/api/v1/catalogs/countries?per_page=100&page='.$page)->assertOk()->assertJsonPath('total', 249)->json('data');
$codes = [...$codes, ...array_column($rows, 'code')];
}
$this->assertCount(249, array_unique($codes));
$this->assertContains('PT', $codes);
$this->assertContains('ZA', $codes);
$this->getJson('/api/v1/catalogs/countries?q=Mocambique')->assertOk()->assertJsonPath('data.0.value', 'MZ');
$this->getJson('/api/v1/catalogs/countries?q=Portugal')->assertOk()->assertJsonPath('data.0.value', 'PT');
$this->getJson('/api/v1/catalogs/countries?parent_id='.Str::uuid())->assertUnprocessable();
$this->getJson('/api/v1/catalogs/countries?per_page=101')->assertUnprocessable();
$this->assertDatabaseCount('geo_locations', 0);
$this->assertDatabaseCount('reference_catalog_versions', 0);
}
public function test_simulator_recovers_iso_countries_in_private_draft_without_reinterpreting_legacy_values(): void
{
$owner = User::factory()->create();
$this->actingAs($owner);
$data = ['personal_snapshot' => ['fiscal_residency_country' => 'PT'],
'economic_snapshot' => ['purpose' => 'simulate_only', 'journey_type' => 'investor', 'tax_calculation' => 'no', 'funds_origin_countries' => ['PT', 'ZA']]];
$headers = ['Idempotency-Key' => (string) Str::uuid()];
$created = $this->postJson('/api/v1/simulation-drafts', $data, $headers)->assertCreated();
$id = $created->json('data.id');
$row = DB::table('simulation_form_drafts')->where('id', $id)->first();
$this->assertSame('PT', SimulationFormDraft::decryptSnapshot($row->personal_snapshot)['fiscal_residency_country']);
$this->assertSame(['PT', 'ZA'], SimulationFormDraft::decryptSnapshot($row->economic_snapshot)['funds_origin_countries']);
$this->getJson('/api/v1/simulation-drafts/'.$id)->assertOk()->assertJsonPath('data.personal_snapshot.fiscal_residency_country', 'PT')->assertJsonPath('data.economic_snapshot.funds_origin_countries', ['PT', 'ZA']);
$this->postJson('/api/v1/simulation-drafts', [...$data, 'personal_snapshot' => ['fiscal_residency_country' => 'ZZ']], ['Idempotency-Key' => (string) Str::uuid()])
->assertUnprocessable()->assertJsonValidationErrors('personal_snapshot.fiscal_residency_country');
$legacy = [...$data, 'personal_snapshot' => ['fiscal_residency_country' => 'Portugal']];
$this->putJson('/api/v1/simulation-drafts/'.$id, $legacy, ['If-Match' => $created->headers->get('ETag')])->assertOk()->assertJsonPath('data.version_no', 2);
$this->actingAs(User::factory()->create())->getJson('/api/v1/simulation-drafts/'.$id)->assertNotFound();
$this->actingAs($owner)->getJson('/api/v1/simulation-drafts/'.$id)->assertJsonPath('data.personal_snapshot.fiscal_residency_country', 'Portugal');
$this->assertDatabaseCount('simulations', 0);
}
public function test_desk_territory_cascade_is_saved_recovered_and_presented_with_origin_without_cross_account_access(): void
{
$owner = User::factory()->create();
$manager = User::factory()->create();
config(['portal.managers' => [$manager->email]]);
$country = $this->territory('country');
$province = $this->territory('province', $country);
$district = $this->territory('district', $province);
$post = $this->territory('admin_post', $district);
$locality = $this->territory('locality', $post);
$private = $this->territory('district', $province, false);
foreach ([['province', null, $province], ['district', $province, $district], ['admin_post', $district, $post], ['locality', $post, $locality]] as [$type, $parent, $selected]) {
$this->getJson('/api/v1/investment/territory-options?'.http_build_query(['type' => $type, 'parent_id' => $parent, 'q' => 'sintetico']))
->assertOk()->assertJsonPath('data.0.id', $selected);
}
$service = $this->actingAs($manager)->postJson('/management/balcao/servicos', ['code' => 'project-preparation', 'confirm' => true])->assertCreated()->json('id');
$case = $this->actingAs($owner)->postJson('/balcao-virtual/dossiers', ['title' => 'Dossier sintético QA', 'client_key' => (string) Str::uuid(), 'reuse_consent' => true])->assertCreated()->json('id');
$id = $this->postJson('/balcao-virtual/dossiers/'.$case.'/pedidos', ['service_version_id' => $service, 'client_key' => (string) Str::uuid()])->assertCreated()->json('id');
$url = '/balcao-virtual/pedidos/'.$id;
$payload = ['title' => 'Projeto sintético QA', 'territory_id' => $locality, 'location_source' => 'Fonte sintética exclusivamente QA', 'declared_origin_country_code' => 'PT'];
$this->putJson($url, ['version' => 1, 'payload' => $payload])->assertOk()->assertJsonPath('version', 2);
$stored = DB::table('desk_requests')->where('id', $id)->value('payload');
$this->assertSame($locality, app(VirtualDesk::class)->decode($stored)['territory_id']);
$this->assertStringNotContainsString($locality, $stored);
$this->get($url)->assertInertia(fn (Assert $page) => $page->component('desk/request')->has('countries', 249)
->where('request.payload.territory_id', $locality)->where('request.payload.declared_origin_country_code', 'PT')
->has('territoryPaths.territory_id', 4)->where('territoryPaths.territory_id.0.id', $province)->where('territoryPaths.territory_id.3.id', $locality));
$this->putJson($url, ['version' => 2, 'payload' => [...$payload, 'territory_id' => $private]])->assertUnprocessable()->assertJsonValidationErrors('payload.territory_id');
$this->getJson($url)->assertJsonPath('request.version', 2)->assertJsonPath('request.payload.territory_id', $locality);
$this->actingAs(User::factory()->create())->getJson($url)->assertForbidden();
$this->actingAs($owner)->getJson($url)->assertJsonPath('territoryPaths.territory_id.3.id', $locality);
$submitted = [...$payload, 'description' => 'Descrição exclusivamente sintética na QA.', 'investment_amount' => '10.00', 'currency' => 'MZN'];
$this->putJson($url, ['version' => 2, 'payload' => $submitted, 'consent' => true, 'submit' => true])->assertOk()->assertJsonPath('status', 'submitted');
$this->actingAs($manager)->get('/management/crm/leads?origin=PT')->assertInertia(fn (Assert $page) => $page
->has('originStatistics.interests', 0)->where('originStatistics.desk.0.code', 'PT')->where('originStatistics.desk.0.count', 1));
Http::assertNothingSent();
}
}
+145
View File
@@ -0,0 +1,145 @@
<?php
namespace Tests\Feature;
use App\Models\ExistingBusiness;
use App\Models\InterestRequest;
use App\Models\LandAssistanceCase;
use App\Models\Opportunity;
use App\Models\User;
use App\Services\DeclaredOriginStatistics;
use App\Services\OpportunityManagement;
use App\Services\OpportunityMaturity;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Str;
use Inertia\Testing\AssertableInertia as Assert;
use Tests\TestCase;
class PortalAuditNextLotTest extends TestCase
{
use RefreshDatabase;
protected function setUp(): void
{
parent::setUp();
$this->withoutVite();
$identity = DB::selectOne('SELECT current_database() AS database,inet_server_port() AS port');
$this->assertStringEndsWith('_test', $identity->database);
$this->assertSame(55441, (int) $identity->port);
config(['portal.notifications_enabled' => false, 'existing-business.enabled' => true, 'investmoz_ai.enabled' => false, 'suitecrm.enabled' => false]);
Http::fake();
}
public function test_eight_maturity_stages_recover_versioned_without_reinterpreting_legacy_or_publishing(): void
{
$manager = User::factory()->create();
config(['portal.managers' => [$manager->email]]);
$this->actingAs($manager);
foreach (array_keys(OpportunityMaturity::STAGES) as $index => $stage) {
$input = ['creation_key' => (string) Str::uuid(), 'reason' => 'Verificação das oito etapas exclusivamente na QA', 'maturity_level' => $index + 1];
if ($index < 6) {
$input['maturity_catalogue_version'] = OpportunityMaturity::VERSION;
}
$result = $this->postJson('/management/opportunities/drafts', $input)->assertCreated()->json();
$record = Opportunity::findOrFail($result['id']);
$stored = json_decode($record->management_draft, true, 512, JSON_THROW_ON_ERROR);
$this->assertSame($stage, $stored['maturity_stage']);
$this->assertSame($index + 1, $stored['maturity_level']);
$this->assertSame(OpportunityMaturity::VERSION, $stored['maturity_catalogue_version']);
$this->assertNull($record->maturity_level);
$this->assertSame('draft', $record->status);
$this->postJson('/management/opportunities/drafts', $input)->assertCreated()->assertJsonPath('id', $record->id);
$this->get('/management/opportunities/'.$record->id.'/edit')->assertInertia(fn (Assert $page) => $page
->has('maturityCatalogue.stages', 8)->where('values.maturity_stage', $stage)->where('values.maturity_catalogue_version', OpportunityMaturity::VERSION));
}
$legacy = $this->postJson('/management/opportunities/drafts', ['creation_key' => (string) Str::uuid(), 'reason' => 'Preservar significado histórico exclusivamente QA', 'maturity_level' => 6])->assertCreated()->json();
$record = Opportunity::findOrFail($legacy['id']);
$label = $record->maturity_label;
$this->assertSame(6, $record->maturity_level);
$this->assertSame(OpportunityMaturity::LEGACY_LABELS[6], $label);
$this->putJson('/management/opportunities/'.$record->id.'/draft', ['etag' => $legacy['etag'], 'reason' => 'Rejeitar conversão implícita do catálogo histórico', 'maturity_catalogue_version' => OpportunityMaturity::VERSION])->assertUnprocessable()->assertJsonValidationErrors('maturity_catalogue_version');
$updated = $this->putJson('/management/opportunities/'.$record->id.'/draft', ['etag' => $legacy['etag'], 'reason' => 'Escolha explícita da nova etapa na versão QA', 'maturity_stage' => 'operational', 'maturity_catalogue_version' => OpportunityMaturity::VERSION])->assertOk()->json();
$this->assertSame(6, $record->fresh()->maturity_level);
$this->assertSame($label, $record->fresh()->maturity_label);
$this->assertSame('operational', app(OpportunityManagement::class)->values($record->fresh())['maturity_stage']);
$this->putJson('/management/opportunities/'.$record->id.'/draft', ['etag' => $updated['etag'], 'reason' => 'Rejeitar etapa e posição incoerentes em QA', 'maturity_level' => 7, 'maturity_stage' => 'operational'])->assertUnprocessable()->assertJsonValidationErrors('maturity_stage');
$this->putJson('/management/opportunities/'.$record->id.'/draft', ['etag' => $legacy['etag'], 'reason' => 'Rejeitar gravação concorrente exclusivamente QA', 'maturity_level' => 8])->assertConflict();
$this->assertSame(9, Opportunity::count());
$this->assertSame(0, Opportunity::published()->count());
$this->actingAs(User::factory()->create())->postJson('/management/opportunities/drafts', ['maturity_level' => 8])->assertForbidden();
}
public function test_business_country_origin_save_recover_filters_submission_statistics_and_actor_isolation(): void
{
$owner = User::factory()->create();
$manager = User::factory()->create();
config(['portal.managers' => [$manager->email]]);
$payload = ['title' => 'Negócio sintético QA', 'country' => 'PT', 'declared_origin_country_code' => 'ZA', 'wizard_version' => '1'];
$input = ['modality' => 'company_sale', 'classification' => 'confidential', 'reason' => 'País e origem independentes exclusivamente QA', 'payload' => $payload];
$this->actingAs($owner)->postJson('/api/v1/existing-business/listings', [...$input, 'payload' => [...$payload, 'country' => 'ZZ']])->assertUnprocessable()->assertJsonValidationErrors('payload.country');
$record = $this->postJson('/api/v1/existing-business/listings', $input)->assertCreated()->assertJsonPath('payload.country', 'PT')->assertJsonPath('payload.declared_origin_country_code', 'ZA')->json();
$url = '/api/v1/existing-business/listings/'.$record['id'];
$this->getJson($url)->assertOk()->assertJsonPath('record.payload.country', 'PT')->assertJsonPath('record.payload.declared_origin_country_code', 'ZA')->assertJsonCount(249, 'countries');
$this->getJson('/api/v1/existing-business/listings?country=PT&origin=ZA')->assertOk()->assertJsonPath('records.total', 1);
$this->getJson('/api/v1/existing-business/listings?origin=PT')->assertOk()->assertJsonPath('records.total', 0);
$this->assertSame([], app(DeclaredOriginStatistics::class)->forManager($manager)['businesses']);
$this->putJson($url, [...$input, 'version_no' => 1, 'payload' => [...$payload, 'country' => 'Portugal']])->assertOk()->assertJsonPath('version_no', 2);
$this->getJson($url)->assertOk()->assertJsonPath('record.payload.country', 'Portugal');
$this->putJson($url, [...$input, 'version_no' => 2, 'payload' => $payload])->assertOk()->assertJsonPath('version_no', 3);
$payload = [...$payload, 'proponent_type' => 'person', 'proponent_name' => 'Promotor QA', 'tax_identifier' => 'SYNTHETIC', 'contact_email' => 'person@example.test', 'acting_capacity' => 'holder', 'declared_holder' => 'Promotor QA', 'location_notes' => 'Localização declarada QA', 'legitimacy_declaration' => 'yes', 'description' => 'Descrição sintética para permitir a submissão deste percurso privado na QA.', 'source_reference' => 'Referência sintética exclusivamente QA', 'valid_until' => today()->addMonth()->toDateString()];
$this->putJson($url, [...$input, 'version_no' => 3, 'payload' => $payload])->assertOk()->assertJsonPath('version_no', 4);
$this->putJson($url, [...$input, 'version_no' => 4, 'payload' => [...$payload, 'declared_origin_country_code' => 'ZZ']])->assertUnprocessable()->assertJsonValidationErrors('payload.declared_origin_country_code');
$this->assertSame(4, ExistingBusiness::findOrFail($record['id'])->version_no);
$this->postJson($url.'/transitions', ['version_no' => 4, 'action' => 'submitted', 'note' => 'Submissão privada exclusivamente na base QA', 'review_consent' => true, 'confirmation_code' => $record['code']])->assertOk()->assertJsonPath('status', 'submitted');
$this->getJson($url)->assertOk()->assertJsonPath('record.payload.declared_origin_country_code', 'ZA')->assertJsonPath('record.payload.country', 'PT');
$stats = app(DeclaredOriginStatistics::class)->forManager($manager, 'ZA');
$this->assertSame(1, $stats['businesses'][0]['count']);
$this->assertSame('ZA', $stats['businesses'][0]['code']);
$this->assertSame([], $stats['interests']);
$this->assertSame([], app(DeclaredOriginStatistics::class)->forManager($manager, 'PT')['businesses']);
$this->actingAs($manager)->get('/management/interests?origin=ZA')->assertInertia(fn (Assert $page) => $page->where('originStatistics.businesses.0.count', 1));
$this->actingAs(User::factory()->create())->getJson($url)->assertForbidden();
$this->getJson('/api/v1/existing-business/listings?country=PT&origin=ZA')->assertOk()->assertJsonPath('records.total', 0);
$this->assertDatabaseCount('existing_business_revisions', 5);
$this->assertStringNotContainsString('person@example.test', (string) DB::table('existing_businesses')->where('id', $record['id'])->value('payload'));
}
public function test_land_country_origin_and_four_territory_levels_recover_in_support_filters_statistics(): void
{
$owner = User::factory()->create();
$manager = User::factory()->create();
config(['portal.managers' => [$manager->email]]);
$path = [];
$parent = (string) Str::uuid();
DB::table('geo_locations')->insert(['id' => $parent, 'code' => $parent, 'source_system' => 'cartografia-workbook', 'external_id' => '964a187ad32ea553b9e112cd159caa90b900050c42b7329594a75bcd798a96ff:'.$parent, 'data_classification' => 'public', 'name' => 'QA country', 'location_type' => 'country']);
foreach (['province', 'district', 'admin_post', 'locality'] as $type) {
$id = (string) Str::uuid();
DB::table('geo_locations')->insert(['id' => $id, 'code' => $id, 'source_system' => 'cartografia-workbook', 'external_id' => '964a187ad32ea553b9e112cd159caa90b900050c42b7329594a75bcd798a96ff:'.$id, 'data_classification' => 'public', 'name' => 'QA '.$type, 'location_type' => $type, 'parent_id' => $parent]);
$path[$type === 'admin_post' ? 'admin_post_id' : $type.'_id'] = $id;
$parent = $id;
}
$payload = ['country' => 'PT', 'declared_origin_country_code' => 'ZA', 'support_kind' => 'investment', 'request_description' => 'Pedido sintético de apoio fundiário exclusivamente QA', 'applicant_category' => 'individual', 'full_name' => 'Pessoa QA', 'email' => 'land@example.test', 'preferred_contact' => 'email', 'area_status' => 'identified', 'purpose' => 'Preparação sintética QA', 'parcels' => [$path], 'contact_consent' => true, 'truth_declaration' => true];
$this->actingAs($owner)->get('/investidor/terra-duat/novo')->assertInertia(fn (Assert $page) => $page->has('countries', 249));
$record = $this->postJson('/investidor/terra-duat', ['payload' => $payload])->assertCreated()->json();
$url = '/investidor/terra-duat/'.$record['id'];
$this->getJson($url)->assertOk()->assertJsonPath('case.payload.country', 'PT')->assertJsonPath('case.payload.declared_origin_country_code', 'ZA')->assertJsonPath('case.payload.parcels.0.locality_id', $parent)->assertJsonCount(4, 'territoryPaths.0')->assertJsonPath('territoryPaths.0.3.id', $parent)->assertJsonPath('declaredOrigin.code', 'ZA');
$this->putJson($url, ['version' => 1, 'payload' => [...$payload, 'country' => 'Moçambique']])->assertOk()->assertJsonPath('version', 2);
$this->getJson($url)->assertOk()->assertJsonPath('case.payload.country', 'Moçambique');
$this->putJson($url, ['version' => 2, 'payload' => [...$payload, 'declared_origin_country_code' => 'ZZ']])->assertUnprocessable()->assertJsonValidationErrors('payload.declared_origin_country_code');
$this->assertSame(2, LandAssistanceCase::findOrFail($record['id'])->version);
$this->postJson($url.'/solicitar-apoio', ['version' => 2, 'payload' => $payload])->assertAccepted();
$interest = InterestRequest::firstOrFail();
$this->assertSame('PT', $interest->country);
$this->assertSame('ZA', $interest->metadata['declared_origin_country_code']);
$this->getJson($url)->assertOk()->assertJsonPath('case.payload.parcels.0.locality_id', $parent)->assertJsonPath('case.payload.declared_origin_country_code', 'ZA');
$this->actingAs($manager)->get('/management/interests?country=PT&origin=ZA')->assertInertia(fn (Assert $page) => $page->where('requests.total', 1)->where('requests.data.0.country', 'PT')->where('requests.data.0.declaredOrigin.code', 'ZA')->where('originStatistics.interests.0.count', 1));
$this->get('/management/interests?origin=PT')->assertInertia(fn (Assert $page) => $page->where('requests.total', 0)->has('originStatistics.interests', 0));
$this->get('/management/interests?origin=unknown')->assertInertia(fn (Assert $page) => $page->where('requests.total', 0));
$this->actingAs(User::factory()->create())->getJson($url)->assertNotFound();
$this->get('/management/interests?origin=ZA')->assertForbidden();
$this->assertDatabaseCount('interest_requests', 1);
Http::assertNothingSent();
}
}
+66
View File
@@ -0,0 +1,66 @@
<?php
namespace Tests\Feature;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Crypt;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Route;
use Tests\TestCase;
class PortalAuditRoutesTest extends TestCase
{
use RefreshDatabase;
public function test_static_portal_get_routes_for_public_investor_and_manager_do_not_crash(): void
{
$this->withoutVite();
$identity = DB::selectOne('SELECT current_database() AS database,inet_server_port() AS port');
$this->assertStringEndsWith('_test', $identity->database);
$this->assertSame(55441, (int) $identity->port);
Http::fake();
$investor = User::factory()->create();
$manager = User::factory()->create();
$reviewer = User::factory()->create();
$reviewer->forceFill(['two_factor_secret' => Crypt::encryptString('QA-SYNTHETIC'), 'two_factor_confirmed_at' => now()])->save();
config(['portal.managers' => [$manager->email, $reviewer->email], 'suitecrm.enabled' => false, 'investmoz_ai.enabled' => false, 'education.enabled' => true]);
foreach (['existing-business' => ['reviewers', 'triagers', 'approvers_l1', 'approvers_l2', 'publishers'], 'investment-zones' => ['legal_reviewers', 'gis_reviewers', 'territory_reviewers', 'publishers'], 'territorial' => ['editors', 'reviewers', 'publishers'], 'simulation-tax-governance' => ['editors', 'reviewers', 'publishers']] as $module => $roles) {
foreach ($roles as $role) {
config([$module.'.'.$role => [$reviewer->email]]);
}
}
$results = [];
$failures = [];
foreach (['public' => null, 'investor' => $investor, 'manager' => $manager, 'designated_mfa_qa' => $reviewer] as $profile => $actor) {
if ($actor) {
$this->actingAs($actor);
} else {
$this->app['auth']->forgetGuards();
}
foreach (Route::getRoutes() as $route) {
$uri = $route->uri();
if (! in_array('GET', $route->methods(), true) || str_contains($uri, '{') || ! str_contains($route->getActionName(), 'App\\Http\\Controllers\\') || str_contains($uri, 'export')) {
continue;
}
$url = '/'.ltrim($uri, '/');
if (str_ends_with($url, '/territory-options')) {
$url .= '?type=province&q=Maputo';
}
$response = $this->get($url);
$results[] = ['profile' => $profile, 'route' => $url, 'status' => $response->status(), 'coverage' => 'HTTP GET, schema migrated and empty QA; not visual acceptance'];
if ($response->status() >= 500) {
$failures[] = $profile.' '.$url.' HTTP '.$response->status();
}
}
}
$dir = storage_path('app/private/investmoz-v2/portal-audit');
if (! is_dir($dir)) {
mkdir($dir, 0700, true);
}
file_put_contents($dir.'/ensaios-rotas.json', json_encode(['captured_at' => gmdate('c'), 'qa' => $identity, 'results' => $results, 'failures' => $failures, 'active_writes' => 0], JSON_PRETTY_PRINT | JSON_THROW_ON_ERROR));
Http::assertNothingSent();
$this->assertSame([], $failures);
}
}
@@ -0,0 +1,58 @@
<?php
namespace Tests\Feature;
use App\Models\InterestRequest;
use App\Models\User;
use App\Services\DeclaredOriginStatistics;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Str;
use Inertia\Testing\AssertableInertia as Assert;
use Tests\TestCase;
class PortalOriginCompatibilityTest extends TestCase
{
use RefreshDatabase;
private function payload(): array
{
return ['fullName' => 'Pessoa sintética QA', 'email' => 'portal-integration-test@gmail.com', 'investorType' => 'individual',
'country' => 'Moçambique', 'sectorInterest' => 'Energia', 'message' => 'Pedido exclusivamente sintético na base QA.', 'privacyConsent' => true];
}
public function test_public_api_preserves_explicit_origin_in_receipt_replay_and_statistics(): void
{
config(['portal.notifications_enabled' => false]);
$headers = ['Idempotency-Key' => (string) Str::uuid()];
$data = [...$this->payload(), 'declared_origin_country_code' => 'PT'];
$this->postJson('/api/v1/interest-requests', [...$data, 'declared_origin_country_code' => 'ZZ'], $headers)->assertUnprocessable()->assertJsonValidationErrors('declared_origin_country_code');
$this->assertDatabaseCount('interest_requests', 0);
$first = $this->postJson('/api/v1/interest-requests', $data, $headers)->assertCreated()->json();
$this->postJson('/api/v1/interest-requests', $data, $headers)->assertCreated()->assertExactJson($first);
$this->postJson('/api/v1/interest-requests', [...$data, 'declared_origin_country_code' => 'ZA'], $headers)->assertConflict();
$interest = InterestRequest::firstOrFail();
$this->assertSame('PT', $interest->metadata['declared_origin_country_code']);
$this->assertSame('Moçambique', $interest->country);
$this->assertDatabaseCount('interest_requests', 1);
$this->postJson('/api/v1/interest-requests', $this->payload(), ['Idempotency-Key' => (string) Str::uuid()])->assertCreated();
$manager = User::factory()->create();
config(['portal.managers' => [$manager->email]]);
$stats = app(DeclaredOriginStatistics::class)->forManager($manager);
$this->assertSame([null, 'PT'], array_column($stats['interests'], 'code'));
$this->assertSame([1, 1], array_column($stats['interests'], 'count'));
}
public function test_support_form_keeps_origin_optional_and_exposes_local_catalogue(): void
{
$this->withoutVite();
$this->get('/apoio')->assertInertia(fn (Assert $page) => $page->has('countries', 249));
$this->get('/manifestar-interesse')->assertInertia(fn (Assert $page) => $page->has('countries', 249));
$data = [...$this->payload(), 'kind' => 'support', 'submission_key' => (string) Str::uuid(), 'declared_origin_country_code' => 'ZA'];
$this->postJson('/apoio', [...$data, 'declared_origin_country_code' => 'ZZ'])->assertUnprocessable();
$this->assertDatabaseCount('interest_requests', 0);
$this->from('/apoio')->post('/apoio', $data)->assertRedirect('/apoio')->assertSessionHasNoErrors();
$this->assertSame('ZA', InterestRequest::firstOrFail()->metadata['declared_origin_country_code']);
$this->post('/apoio', $data)->assertSessionHasNoErrors();
$this->assertDatabaseCount('interest_requests', 1);
}
}
@@ -0,0 +1,141 @@
<?php
namespace Tests\Feature;
use App\Jobs\ScanDocumentForMalware;
use App\Models\User;
use App\Services\Malware\MalwareScanner;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Http\UploadedFile;
use Illuminate\Support\Facades\Crypt;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Queue;
use Illuminate\Support\Facades\Storage;
use Illuminate\Support\Str;
use Tests\TestCase;
class PortalRealDocumentConsumersTest extends TestCase
{
use RefreshDatabase;
protected function setUp(): void
{
parent::setUp();
Storage::fake('local');
Queue::fake();
config(['services.malware.scanner' => 'clamd', 'services.malware.host' => '127.0.0.1', 'services.malware.port' => 3310]);
if (! app(MalwareScanner::class)->health()['available']) {
$this->markTestSkipped('Real ClamD required for these consumer operations.');
}
}
private function scan(string $id): void
{
(new ScanDocumentForMalware($id))->handle(app(MalwareScanner::class));
$d = DB::table('documents')->where('id', $id)->first();
$this->assertSame('clean', $d->malware_scan_status);
$this->assertSame('ClamAV', $d->malware_engine);
$this->assertSame(trim($d->sha256), $d->malware_scanned_sha256);
$this->assertNotNull($d->malware_signatures_at);
}
public function test_library_private_registration_real_scan_explicit_classification_publication_and_download(): void
{
$manager = User::factory()->create();
config(['portal.managers' => [$manager->email]]);
$bytes = 'Synthetic QA guide; no official or legal value.';
$upload = ['file' => UploadedFile::fake()->createWithContent('qa-guide.txt', $bytes)];
$this->actingAs($manager)->post('/management/portal-services/upload', $upload)->assertSessionHasNoErrors();
$d = DB::table('documents')->first();
$this->assertSame('quarantined', $d->status);
$payload = ['document_id' => $d->id, 'title' => 'Synthetic QA guide', 'description' => 'Synthetic quality assurance document.', 'category' => 'Guia', 'topic' => 'Investimento',
'document_type' => 'guide', 'issuer' => 'Synthetic QA only', 'source_url' => 'https://example.invalid/qa', 'language' => 'pt', 'version_label' => 'QA-1',
'territorial_scope' => 'national', 'source_confirmation' => 'identified', 'legal_status' => 'not_applicable', 'rights_status' => 'unconfirmed', 'data_classification' => 'internal',
'source_reference' => 'Synthetic QA fixture, not an official source.', 'legislation' => false, 'essential' => false, 'independent_review' => false, 'associations' => []];
$response = $this->post('/management/portal-services/resources', $payload);
$this->assertSame(302, $response->status(), $response->getContent());
$response->assertSessionHasNoErrors();
$r = DB::table('portal_resources')->first();
$this->post('/management/portal-services/resources/'.$r->id, ['published' => true])->assertSessionHasErrors('published');
$this->get('/documentos/'.$r->id.'/download')->assertNotFound();
$this->scan($d->id);
$this->assertDatabaseHas('portal_resources', ['id' => $r->id, 'published' => false]);
$update = [...$payload, 'metadata_version' => 1, 'rights_status' => 'authorized', 'data_classification' => 'public'];
$this->put('/management/portal-services/resources/'.$r->id, $update)->assertSessionHasNoErrors();
$this->get('/documentos/'.$r->id.'/download')->assertNotFound();
$this->post('/management/portal-services/resources/'.$r->id, ['published' => true])->assertSessionHasNoErrors();
$this->get('/documentos/'.$r->id.'/download')->assertOk()->assertDownload('qa-guide.txt');
$this->put('/management/portal-services/resources/'.$r->id, $update)->assertConflict();
$this->post('/management/portal-services/upload', $upload)->assertSessionHasErrors('file');
$this->assertSame(1, DB::table('documents')->count());
$this->assertSame(3, DB::table('portal_resource_revisions')->count());
Storage::disk('local')->put($d->storage_key, 'Tampered synthetic QA bytes');
$this->get('/documentos/'.$r->id.'/download')->assertNotFound();
$this->actingAs(User::factory()->create())->put('/management/portal-services/resources/'.$r->id, [...$update, 'metadata_version' => 2])->assertForbidden();
}
public function test_desk_upload_real_scan_dedup_recovery_foreign_actor_and_tamper(): void
{
$owner = User::factory()->create();
$this->actingAs($owner);
$id = $this->postJson('/balcao-virtual/dossiers', ['title' => 'Synthetic document QA case', 'client_key' => (string) Str::uuid(), 'reuse_consent' => true])->assertCreated()->json('id');
$base = '/balcao-virtual/dossiers/'.$id;
$bytes = 'Synthetic confidential QA attachment only.';
$input = ['version' => 1, 'category' => 'proof', 'source_reference' => 'Synthetic QA owner attachment.', 'consent' => true, 'file' => UploadedFile::fake()->createWithContent('qa-desk.txt', $bytes)];
$this->post($base.'/documentos', $input, ['Accept' => 'application/json'])->assertOk();
$link = DB::table('desk_documents')->first();
$d = DB::table('documents')->where('id', $link->document_id)->first();
$url = $base.'/documentos/'.$link->id;
$this->get($url)->assertNotFound();
$this->scan($d->id);
$this->get($url)->assertOk()->assertDownload('qa-desk.txt');
$this->actingAs(User::factory()->create())->get($url)->assertForbidden();
$this->actingAs($owner)->getJson($base)->assertOk();
$version = DB::table('desk_cases')->where('id', $id)->value('version');
$this->post($base.'/documentos', [...$input, 'version' => $version], ['Accept' => 'application/json'])->assertOk();
$this->assertSame(1, DB::table('desk_documents')->count());
$this->assertSame(1, DB::table('documents')->count());
$this->assertDatabaseHas('documents', ['id' => $d->id, 'data_classification' => 'confidential']);
Storage::disk('local')->put($d->storage_key, 'Tampered QA bytes');
$this->get($url)->assertNotFound();
}
public function test_encrypted_canonical_original_is_reusable_in_desk_and_manager_library_download(): void
{
$owner = User::factory()->create();
config(['portal.managers' => [$owner->email]]);
$bytes = "%PDF-1.4\n% Synthetic encrypted canonical QA only.\n%%EOF";
$doc = (string) Str::uuid();
$key = 'qa-canonical/'.$doc;
Storage::disk('local')->put($key, Crypt::encryptString($bytes));
DB::table('documents')->insert(['id' => $doc, 'source_system' => 'qa', 'file_name' => 'encrypted-qa.pdf', 'media_type' => 'application/pdf', 'storage_key' => $key,
'sha256' => hash('sha256', $bytes), 'size_bytes' => strlen($bytes), 'storage_encryption' => 'laravel', 'status' => 'quarantined', 'data_classification' => 'confidential', 'uploaded_by' => $owner->id]);
$this->scan($doc);
$this->actingAs($owner)->get('/management/documents/'.$doc.'/download')->assertOk()->assertContent($bytes);
$case = $this->postJson('/balcao-virtual/dossiers', ['title' => 'Synthetic encrypted reuse QA', 'client_key' => (string) Str::uuid(), 'reuse_consent' => true])->assertCreated()->json('id');
$this->postJson('/balcao-virtual/dossiers/'.$case.'/documentos', ['version' => 1, 'document_id' => $doc, 'category' => 'proof', 'source_reference' => 'Existing owned encrypted QA original', 'consent' => true])->assertOk();
$link = DB::table('desk_documents')->where('document_id', $doc)->first();
$url = '/balcao-virtual/dossiers/'.$case.'/documentos/'.$link->id;
$this->get($url)->assertOk()->assertContent($bytes);
$this->assertSame(1, DB::table('documents')->count());
$metadata = ['document_id' => $doc, 'title' => 'Synthetic encrypted QA guide', 'description' => 'Synthetic QA only.', 'category' => 'Guia', 'topic' => 'Investimento',
'document_type' => 'guide', 'source_url' => 'https://example.invalid/qa', 'language' => 'pt', 'version_label' => 'QA-1', 'territorial_scope' => 'national',
'source_confirmation' => 'identified', 'legal_status' => 'not_applicable', 'rights_status' => 'authorized', 'data_classification' => 'public',
'source_reference' => 'Synthetic QA, not an official source.', 'legislation' => false, 'essential' => false, 'independent_review' => false, 'associations' => []];
$this->post('/management/portal-services/resources', $metadata)->assertSessionHasNoErrors();
$resource = DB::table('portal_resources')->first();
$public = '/documentos/'.$resource->id;
$this->get($public.'/download')->assertNotFound();
$this->get($public.'/view')->assertNotFound();
$this->post('/management/portal-services/resources/'.$resource->id, ['published' => true])->assertSessionHasNoErrors();
$this->get($public.'/download')->assertOk()->assertContent($bytes);
$this->get($public.'/view')->assertOk()->assertContent($bytes)->assertHeader('Content-Type', 'application/pdf')
->assertHeader('Content-Security-Policy', "sandbox; default-src 'none'; style-src 'unsafe-inline'");
$this->assertSame(1, DB::table('documents')->count());
Storage::disk('local')->put($key, Crypt::encryptString('Changed synthetic QA bytes'));
$this->get($url)->assertNotFound();
$this->get('/management/documents/'.$doc.'/download')->assertNotFound();
$this->get($public.'/download')->assertNotFound();
$this->get($public.'/view')->assertNotFound();
}
}
+12 -4
View File
@@ -367,14 +367,22 @@ class SimulationExecutionTest extends TestCase
$this->assertDatabaseHas('simulation_jobs', ['simulation_id' => $simulation->id, 'status' => 'failed', 'attempts' => 3]);
}
public function test_empty_schema_rolls_back_and_reapplies_with_questionnaire_seed(): void
public function test_core_rollback_refuses_published_path_definition_and_preserves_rls(): void
{
$migration = require database_path('migrations/2026_10_01_000200_create_simulation_execution_core.php');
$migration->down();
$this->assertFalse(Schema::hasTable('simulations'));
$migration->up();
// The full installation is no longer a base-only empty schema: it owns a second immutable definition.
$before = DB::select('SELECT relname,relrowsecurity,relforcerowsecurity FROM pg_class WHERE relnamespace = to_regnamespace(?) AND relname IN (?,?) ORDER BY relname', ['public', 'simulations', 'questionnaire_versions']);
try {
$migration->down();
$this->fail('A published path definition must not be silently erased by fiscal core rollback.');
} catch (\RuntimeException $exception) {
$this->assertSame('Cannot rollback simulation execution: questionnaire_versions contains data.', $exception->getMessage());
}
$after = DB::select('SELECT relname,relrowsecurity,relforcerowsecurity FROM pg_class WHERE relnamespace = to_regnamespace(?) AND relname IN (?,?) ORDER BY relname', ['public', 'simulations', 'questionnaire_versions']);
$this->assertEquals($before, $after);
$this->assertTrue(Schema::hasTable('simulation_results'));
$this->assertDatabaseHas('questionnaire_versions', ['id' => '61000100-0000-4000-8000-000000000001', 'status' => 'published']);
$this->assertDatabaseHas('questionnaire_versions', ['code' => 'investor-path-19', 'status' => 'published']);
}
public function test_questionnaire_endpoint_exposes_only_published_version_with_etag(): void
@@ -0,0 +1,148 @@
<?php
namespace Tests\Feature;
use App\Models\User;
use App\Services\Malware\MalwareScanner;
use App\Services\TerritoryGeometryImport;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Crypt;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Str;
use Symfony\Component\HttpKernel\Exception\HttpException;
use Tests\TestCase;
class TerritoryGeometryImportTest extends TestCase
{
use RefreshDatabase;
private string $directory;
private function setupImport(): User
{
$this->directory = storage_path('app/private/testing/geometry-import-'.Str::uuid());
mkdir($this->directory, 0700, true);
$originals = storage_path('app/private/investmoz-v2/portal-audit');
foreach (['moz-adm1-ine-20190607.geojson', 'moz-adm1-ine-metadata.html'] as $file) {
copy($originals.'/'.$file, $this->directory.'/'.$file);
}
$file = $this->directory.'/moz-adm1-ine-20190607.geojson';
config(['services.malware.scanner' => 'clamd', 'services.malware.host' => '127.0.0.1', 'services.malware.port' => 3310]);
$scanner = app(MalwareScanner::class);
if (! $scanner->health()['available']) {
$this->markTestSkipped('Real local ClamD required; no fake clean evidence.');
}
$scan = $scanner->scan($file, filesize($file), storage_path('app/private'));
$this->assertSame('clean', $scan['result']);
$scan['signatures_at'] = $scan['signatures_at']?->format(DATE_ATOM);
file_put_contents($this->directory.'/geometry-source-scans.json', json_encode(['captured_at' => gmdate('c'), 'files' => [['file' => basename($file), 'sha256' => hash_file('sha256', $file), 'scan' => $scan]]], JSON_THROW_ON_ERROR));
$features = json_decode(file_get_contents($file), true)['features'];
$country = (string) Str::uuid();
DB::table('geo_locations')->insert(['id' => $country, 'code' => 'MZ', 'name' => 'Moçambique', 'location_type' => 'country', 'data_classification' => 'internal']);
foreach (array_slice($features, 0, 2) as $f) {
DB::table('geo_locations')->insert(['id' => (string) Str::uuid(), 'code' => (string) Str::uuid(), 'name' => $f['properties']['ADM1_PT'], 'location_type' => 'province', 'parent_id' => $country, 'data_classification' => 'internal']);
}
$foreignCountry = (string) Str::uuid();
DB::table('geo_locations')->insert(['id' => $foreignCountry, 'code' => 'QA-FOREIGN', 'name' => 'Synthetic foreign QA only', 'location_type' => 'country', 'data_classification' => 'internal']);
DB::table('geo_locations')->insert(['id' => (string) Str::uuid(), 'code' => (string) Str::uuid(), 'name' => $features[0]['properties']['ADM1_PT'], 'location_type' => 'province', 'parent_id' => $foreignCountry, 'data_classification' => 'internal']);
$actor = User::factory()->create();
$actor->forceFill(['two_factor_secret' => Crypt::encryptString('QA-TEST-ONLY'), 'two_factor_confirmed_at' => now()])->save();
config(['portal.managers' => [$actor->email], 'geometry-import.enabled' => true, 'geometry-import.directory' => $this->directory]);
$this->actingAs($actor);
return $actor;
}
protected function tearDown(): void
{
if (isset($this->directory) && is_dir($this->directory)) {
foreach (glob($this->directory.'/*') as $file) {
unlink($file);
}
rmdir($this->directory);
}
parent::tearDown();
}
private function input(array $plan): array
{
return ['level' => 1, 'hash' => $plan['hash'], 'idempotency_key' => (string) Str::uuid(), 'reason' => 'Ensaio autenticado de cartografia apenas em QA', 'confirm' => true];
}
public function test_preview_apply_replay_reuse_and_reverse_preserve_history(): void
{
$actor = $this->setupImport();
$plan = $this->getJson('/management/territory-geometry-import?level=1')->assertOk()->assertJsonPath('expected', 11)->assertJsonPath('resolved', 2)->json();
$input = $this->input($plan);
$result = $this->postJson('/management/territory-geometry-import', $input)->assertOk()->assertJsonPath('inserted', 2)->assertJsonPath('published', false)->json();
$this->postJson('/management/territory-geometry-import', $input)->assertOk()->assertExactJson($result);
$this->postJson('/management/territory-geometry-import', [...$input, 'reason' => 'Chave com outro conteúdo: recusar sem escrever'])->assertConflict();
$this->assertSame(2, DB::table('geo_geometries')->count());
$reusePlan = $this->getJson('/management/territory-geometry-import?level=1')->json();
$reuseResponse = $this->postJson('/management/territory-geometry-import', $this->input($reusePlan));
$this->assertSame(200, $reuseResponse->status(), $reuseResponse->getContent());
$reuse = $reuseResponse->assertJsonPath('inserted', 0)->assertJsonPath('reused', 2)->json();
$reverse = ['idempotency_key' => (string) Str::uuid(), 'reason' => 'Reversão do lote QA mantendo todo o histórico', 'confirm' => true];
$this->postJson('/management/territory-geometry-import/'.$reuse['batch_id'].'/reverse', $reverse)->assertOk()->assertJsonPath('reversed', 0)->assertJsonPath('reused_preserved', 2);
$this->assertSame(2, DB::table('geo_geometries')->whereNull('deleted_at')->count());
$url = '/management/territory-geometry-import/'.$result['batch_id'].'/reverse';
$reverse['idempotency_key'] = (string) Str::uuid();
$this->postJson($url, $reverse)->assertOk()->assertJsonPath('reversed', 2);
$this->postJson($url, $reverse)->assertOk()->assertJsonPath('reversed', 2);
$this->assertSame(0, DB::table('geo_geometries')->whereNull('deleted_at')->count());
$this->assertSame(2, DB::table('geo_geometries')->count());
$this->assertSame(4, DB::table('audit_events')->where('source_system', 'geometry-import')->where('actor_user_id', $actor->id)->count());
// Reversed identities stay reserved; do not silently resurrect or duplicate.
$this->postJson('/management/territory-geometry-import', $this->input($this->getJson('/management/territory-geometry-import?level=1')->json()))->assertConflict();
}
public function test_stale_preview_changed_geometry_mfa_and_foreign_actor_are_rejected(): void
{
$actor = $this->setupImport();
$plan = $this->getJson('/management/territory-geometry-import?level=1')->json();
DB::table('geo_locations')->where('name', $plan['rows'][0]['name'])->update(['name' => 'Changed QA only']);
$this->postJson('/management/territory-geometry-import', $this->input($plan))->assertConflict();
$plan = $this->getJson('/management/territory-geometry-import?level=1')->json();
$result = $this->postJson('/management/territory-geometry-import', $this->input($plan))->assertOk()->assertJsonPath('inserted', 1)->json();
DB::table('geo_geometries')->where('id', $result['created'][0]['id'])->update(['data_classification' => 'public']);
$this->postJson('/management/territory-geometry-import/'.$result['batch_id'].'/reverse', ['idempotency_key' => (string) Str::uuid(), 'reason' => 'Recusar reversão de geometria posteriormente publicada', 'confirm' => true])->assertConflict();
$actor->forceFill(['two_factor_confirmed_at' => null])->save();
$this->postJson('/management/territory-geometry-import', $this->input($plan))->assertForbidden();
$this->actingAs(User::factory()->create())->getJson('/management/territory-geometry-import?level=1')->assertForbidden();
$this->postJson('/management/territory-geometry-import', $this->input($plan))->assertForbidden();
}
public function test_expired_scan_and_non_qa_target_cannot_write(): void
{
$this->setupImport();
$path = $this->directory.'/geometry-source-scans.json';
$proof = json_decode(file_get_contents($path), true);
$proof['captured_at'] = '2000-01-01T00:00:00Z';
file_put_contents($path, json_encode($proof));
$plan = $this->getJson('/management/territory-geometry-import?level=1')->assertOk()->assertJsonPath('scan_ready', false)->json();
$this->postJson('/management/territory-geometry-import', $this->input($plan))->assertStatus(423);
$this->app->detectEnvironment(fn () => 'production');
try {
app(TerritoryGeometryImport::class)->apply(auth()->user(), $this->input($plan));
$this->fail('An active target cannot bypass the backup and evidence guard.');
} catch (HttpException $e) {
$this->assertSame(422, $e->getStatusCode());
} finally {
$this->app->detectEnvironment(fn () => 'testing');
}
$this->assertSame(0, DB::table('geo_geometries')->count());
}
public function test_reversal_refuses_a_real_foreign_key_consumer_and_is_atomic(): void
{
$this->setupImport();
$plan = $this->getJson('/management/territory-geometry-import?level=1')->json();
$result = $this->postJson('/management/territory-geometry-import', $this->input($plan))->assertOk()->json();
// Transactional QA-only fixture, rolled back by RefreshDatabase; never a product migration.
DB::statement('CREATE TABLE geometry_import_qa_consumer (geometry_id uuid REFERENCES geo_geometries(id))');
DB::table('geometry_import_qa_consumer')->insert(['geometry_id' => $result['created'][1]['id']]);
$this->postJson('/management/territory-geometry-import/'.$result['batch_id'].'/reverse', ['idempotency_key' => (string) Str::uuid(), 'reason' => 'Reversão deve recusar qualquer consumidor ligado ao lote', 'confirm' => true])->assertConflict();
$this->assertSame(2, DB::table('geo_geometries')->whereNull('deleted_at')->count());
$this->assertSame(0, DB::table('audit_events')->where('action', 'geometry_import_reversed')->count());
}
}
+17
View File
@@ -9,6 +9,9 @@ use Laravel\Fortify\Features;
abstract class TestCase extends BaseTestCase
{
/** @var array<string, resource> Locks held until this test process exits. */
private static array $qaDatabaseLocks = [];
public function createApplication(): Application
{
$app = parent::createApplication();
@@ -28,6 +31,20 @@ abstract class TestCase extends BaseTestCase
|| ! in_array($url, [null, ''], true)) {
throw new \LogicException('PHPUnit PostgreSQL target rejected: configure an explicit loopback *_test database with no DB_URL.');
}
// Two test processes must not migrate/clean the same isolated database concurrently.
$lockKey = hash('sha256', $host.':'.($connection['port'] ?? '').':'.$database);
if (! isset(self::$qaDatabaseLocks[$lockKey])) {
$handle = fopen($app->storagePath('framework/qa-database-'.$lockKey.'.lock'), 'c');
if ($handle === false) {
throw new \LogicException('QA database execution lock unavailable.');
}
if (! flock($handle, LOCK_EX | LOCK_NB)) {
fclose($handle);
throw new \LogicException('Another test process owns this QA database; run suites sequentially or use separate *_test databases.');
}
self::$qaDatabaseLocks[$lockKey] = $handle;
}
}
// migrate:fresh only clears the connection's public search path. This module owns a separate schema.
+57
View File
@@ -0,0 +1,57 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { recoverPathResult } from '../../resources/js/lib/investor-path.ts';
import { serializeFinancialToolsSnapshot } from '../../resources/js/lib/financial-calculators.ts';
const inputs = {
budgetPeriod: 'Mensal',
loan: {
principal: '100000',
rate: '0',
months: '24',
rateType: 'nominal',
periodMonths: '1',
gracePeriods: '0',
upfrontFee: '0',
recurringFee: '0',
},
breakEven: { fixed: '1000', price: '100', variable: '50' },
funding: { total: '100000', secured: '30000' },
incomes: [{ label: 'Receita QA', amount: '100' }],
expenses: [{ label: 'Custo QA', amount: '50' }],
};
void test('A/B returned dossier evidence reuses the tool formulas after a JSON round trip', () => {
const result = JSON.parse(
JSON.stringify({
engine: 'financial-calculators:v1',
financial_snapshot: serializeFinancialToolsSnapshot(inputs),
}),
);
const recovered = recoverPathResult(result);
assert.equal(recovered.breakEven.value.breakEvenUnits, '20');
assert.equal(recovered.funding.value.gap, '70000.00');
});
void test('missing or invalid financial evidence stays indeterminate without hiding an independent calculation', () => {
const recovered = recoverPathResult({
engine: 'financial-calculators:v1',
financial_snapshot: serializeFinancialToolsSnapshot({
...inputs,
breakEven: { fixed: '', price: '100', variable: '50' },
}),
});
assert.equal(recovered.breakEven.value, null);
assert.equal(recovered.funding.value.gap, '70000.00');
assert.throws(
() =>
recoverPathResult({ engine: 'unknown', financial_snapshot: '{}' }),
/Versão/,
);
assert.throws(
() =>
recoverPathResult({
engine: 'financial-calculators:v1',
financial_snapshot: '{}',
}),
/Entradas/,
);
});