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

283 lines
19 KiB
PHP

<?php
namespace Tests\Feature;
use App\Models\Opportunity;
use App\Models\User;
use App\Services\OpportunityProfileWorkflow;
use App\Services\OpportunityPromotion;
use App\Services\PortalProfileAdministration;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Foundation\Vite;
use Illuminate\Support\Facades\Crypt;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Str;
use Inertia\Testing\AssertableInertia as Assert;
use Symfony\Component\HttpKernel\Exception\HttpException;
use Tests\TestCase;
final class PrivateOfferPreparationTest extends TestCase
{
use RefreshDatabase;
private const BASE = '/minhas-oportunidades/ofertas';
protected function setUp(): void
{
parent::setUp();
$this->withoutVite();
config(['portal.profile_access_enabled' => true]);
}
private function promoter(string $category = 'business_sale', ?string $organization = null): User
{
$user = User::factory()->create();
app(PortalProfileAdministration::class)->insertGrant((string) Str::uuid(), User::factory()->create(), [
'user_id' => $user->id, 'role' => 'promoter', 'organization_id' => $organization,
'opportunity_types' => [$category], 'source' => 'synthetic-test-fixture',
]);
return $user;
}
private function payload(): array
{
return ['creation_key' => (string) Str::uuid(), 'opportunity_category' => 'business_sale',
'title' => 'Empresa sintética QA', 'description' => 'Descrição sintética privada para preparação de uma oferta.',
'private_offer' => ['price_mode' => 'amount', 'amount' => '123456.78', 'currency' => 'MZN', 'contact_id' => null],
'reason' => 'Preparação sintética da oferta na base isolada QA.'];
}
private function contact(User $owner): string
{
$id = (string) Str::uuid();
DB::table('contacts')->insert(['id' => $id, 'full_name' => 'Contacto sintético QA', 'email' => 'offer-'.Str::uuid().'@example.test',
'phone' => '+258840000000', 'source_system' => 'portal-user-contact', 'external_id' => 'user:'.$owner->id]);
return $id;
}
public function test_private_creation_recovery_edit_conflict_and_idempotency_use_one_opportunity(): void
{
$actor = $this->promoter();
$payload = $this->payload();
$saved = $this->actingAs($actor)->postJson(self::BASE.'/rascunhos', $payload)->assertCreated()->json('record');
$this->postJson(self::BASE.'/rascunhos', $payload)->assertCreated()->assertJsonPath('record.id', $saved['id']);
$this->assertSame(1, Opportunity::count());
$this->assertDatabaseHas('opportunities', ['id' => $saved['id'], 'owner_user_id' => $actor->id, 'status' => 'draft', 'published_at' => null, 'estimated_investment_amount' => null]);
$this->getJson(self::BASE.'/'.$saved['id'])->assertOk()->assertHeader('Cache-Control', 'no-store, private')->assertJsonPath('record.values.private_offer.amount', '123456.78');
$this->get(self::BASE.'/'.$saved['id'])->assertInertia(fn (Assert $page) => $page->component('offers/preparation')->where('record.canEdit', true));
$updated = $this->postJson(self::BASE.'/'.$saved['id'].'/guardar', ['etag' => $saved['etag'], 'title' => 'Título revisto QA',
'private_offer' => ['price_mode' => 'negotiable', 'amount' => null, 'currency' => null], 'reason' => $payload['reason']])->assertOk()->json('record');
$this->assertSame($saved['id'], $updated['id']);
$this->getJson(self::BASE.'/'.$saved['id'])->assertJsonPath('record.values.title', 'Título revisto QA')->assertJsonPath('record.values.private_offer.price_mode', 'negotiable');
$this->postJson(self::BASE.'/'.$saved['id'].'/guardar', ['etag' => $saved['etag'], 'title' => 'Edição concorrente', 'reason' => $payload['reason']])->assertConflict();
$this->assertDatabaseHas('opportunities', ['id' => $saved['id'], 'title' => 'Título revisto QA']);
}
public function test_incomplete_draft_is_saved_but_submission_requires_common_fields_and_authorized_contact(): void
{
$actor = $this->promoter();
$saved = $this->actingAs($actor)->postJson(self::BASE.'/rascunhos', ['creation_key' => (string) Str::uuid(),
'opportunity_category' => 'business_sale', 'private_offer' => ['price_mode' => null], 'reason' => $this->payload()['reason']])->assertCreated()->json('record');
$this->postJson(self::BASE.'/'.$saved['id'].'/submeter', ['etag' => $saved['etag'], 'confirm' => true])->assertUnprocessable()->assertJsonValidationErrors(['title', 'description', 'private_offer.contact_id']);
$this->assertDatabaseHas('opportunities', ['id' => $saved['id'], 'status' => 'draft', 'published_at' => null]);
}
public function test_submit_is_private_exact_version_confirmed_and_recoverable_without_editorial_actions(): void
{
$actor = $this->promoter();
$payload = $this->payload();
$payload['private_offer']['contact_id'] = $this->contact($actor);
$saved = $this->actingAs($actor)->postJson(self::BASE.'/rascunhos', $payload)->assertCreated()->json('record');
$url = self::BASE.'/'.$saved['id'].'/submeter';
$this->postJson($url, ['etag' => $saved['etag'], 'confirm' => false])->assertUnprocessable();
$this->postJson($url, ['etag' => str_repeat('0', 64), 'confirm' => true])->assertConflict();
$submitted = $this->postJson($url, ['etag' => $saved['etag'], 'confirm' => true])->assertOk()->assertJsonPath('record.state', 'submitted')->assertJsonPath('record.canEdit', false)->json('record');
$this->postJson($url, ['etag' => $submitted['etag'], 'confirm' => true])->assertOk();
$this->getJson(self::BASE.'/'.$saved['id'])->assertOk()->assertJsonPath('record.state', 'submitted');
$this->postJson(self::BASE.'/'.$saved['id'].'/guardar', ['etag' => $submitted['etag'], 'reason' => $payload['reason']])->assertForbidden();
$record = Opportunity::findOrFail($saved['id']);
$this->assertSame([], app(OpportunityProfileWorkflow::class)->actions($actor, $record));
$this->assertFalse(app(OpportunityPromotion::class)->allowed($actor, $record));
$this->assertNull($record->published_at);
$this->assertSame('draft', $record->status);
$this->assertSame(1, DB::table('audit_events')->where('action', 'private_offer_submitted')->count());
$this->assertDatabaseCount('portal_editorial_editions', 0);
$this->assertDatabaseCount('portal_editorial_notifications', 0);
}
public function test_other_accounts_ungranted_unverified_and_revoked_actors_cannot_operate(): void
{
$this->get(self::BASE.'/criar')->assertRedirect('/login');
$ungranted = User::factory()->create();
$this->actingAs($ungranted)->postJson(self::BASE.'/rascunhos', $this->payload())->assertForbidden();
$this->actingAs(User::factory()->unverified()->create())->get(self::BASE.'/criar')->assertRedirect('/email/verify');
$actor = $this->promoter();
$saved = $this->actingAs($actor)->postJson(self::BASE.'/rascunhos', $this->payload())->assertCreated()->json('record');
$other = $this->promoter();
$this->actingAs($other)->getJson(self::BASE.'/'.$saved['id'])->assertForbidden();
$this->postJson(self::BASE.'/'.$saved['id'].'/guardar', ['etag' => $saved['etag']])->assertForbidden();
$this->postJson(self::BASE.'/'.$saved['id'].'/submeter', ['etag' => $saved['etag'], 'confirm' => true])->assertForbidden();
$this->get(self::BASE)->assertInertia(fn (Assert $page) => $page->has('records.data', 0));
DB::table('portal_role_assignments')->where('user_id', $actor->id)->update(['starts_at' => now()->subHour(), 'expires_at' => now()->subMinute()]);
$this->actingAs($actor)->getJson(self::BASE.'/'.$saved['id'])->assertForbidden();
}
public function test_contact_scope_and_malicious_fields_roll_back_without_parallel_records(): void
{
$actor = $this->promoter();
$payload = $this->payload();
$payload['private_offer']['contact_id'] = $this->contact($this->promoter());
$this->actingAs($actor)->postJson(self::BASE.'/rascunhos', $payload)->assertUnprocessable()->assertJsonValidationErrors('private_offer.contact_id');
$this->assertDatabaseCount('opportunities', 0);
$payload = $this->payload();
foreach (['status' => 'published', 'owner_user_id' => $actor->id, 'workflow_context' => ['publication' => ['visibility' => 'public']]] as $key => $value) {
$this->postJson(self::BASE.'/rascunhos', [...$payload, $key => $value])->assertUnprocessable();
}
$this->postJson(self::BASE.'/rascunhos', [...$payload, 'opportunity_category' => 'real_estate'])->assertForbidden();
$this->assertDatabaseCount('opportunities', 0);
}
public function test_price_contract_rejects_conversion_modes_precision_and_negotiable_amount(): void
{
$actor = $this->promoter();
foreach ([['price_mode' => 'amount', 'amount' => '10.123', 'currency' => 'MZN'], ['price_mode' => 'amount', 'amount' => '100', 'currency' => 'EUR'],
['price_mode' => 'negotiable', 'amount' => '100', 'currency' => 'USD'], ['price_mode' => 'amount', 'amount' => '-1', 'currency' => 'USD'], ['price_mode' => 'amount', 'amount' => 100.01, 'currency' => 'USD']] as $price) {
$this->actingAs($actor)->postJson(self::BASE.'/rascunhos', [...$this->payload(), 'private_offer' => $price])->assertUnprocessable();
}
$this->assertDatabaseCount('opportunities', 0);
}
public function test_no_public_exposure_and_native_routes_cannot_edit_or_publish_offer(): void
{
$actor = $this->promoter();
$saved = $this->actingAs($actor)->postJson(self::BASE.'/rascunhos', $this->payload())->assertCreated()->json('record');
$id = $saved['id'];
$this->putJson('/minhas-oportunidades/'.$id.'/draft', ['etag' => $saved['etag'], 'title' => 'Contorno', 'reason' => $this->payload()['reason']])->assertForbidden();
foreach (['submit', 'approve', 'publish'] as $action) {
$this->postJson('/minhas-oportunidades/'.$id.'/workflow', ['etag' => $saved['etag'], 'action' => $action, 'reason' => $this->payload()['reason']])->assertForbidden();
}
$this->postJson('/minhas-oportunidades/'.$id.'/divulgacao/confirmar', [])->assertForbidden();
$this->get('/minhas-oportunidades/'.$id.'/edit')->assertRedirect(self::BASE.'/'.$id);
$record = Opportunity::findOrFail($id);
$this->assertFalse(app(OpportunityPromotion::class)->allowed($actor, $record));
$this->actingAs(User::factory()->create())->getJson('/api/v1/opportunities/'.$record->slug)->assertNotFound();
$this->get('/oportunidades/'.$record->slug)->assertNotFound();
$this->getJson('/api/v1/opportunities/search?q=Empresa')->assertOk()->assertJsonPath('total', 0);
// Defence in depth: even inconsistent data from an external writer is excluded.
DB::table('opportunities')->where('id', $id)->update(['status' => 'published', 'published_at' => now()->subMinute(),
'workflow_context' => json_encode([...$record->workflow_context, 'publication' => ['visibility' => 'public']])]);
$this->assertSame(0, Opportunity::published()->count());
$this->getJson('/api/v1/opportunities/'.$record->slug)->assertNotFound();
}
public function test_existing_opportunity_cannot_be_converted_through_offer_adapter(): void
{
$actor = $this->promoter();
$native = $this->actingAs($actor)->postJson('/minhas-oportunidades/drafts', $this->payload())->assertCreated()->json();
$this->getJson(self::BASE.'/'.$native['id'])->assertNotFound();
$this->postJson(self::BASE.'/'.$native['id'].'/guardar', ['etag' => $native['etag']])->assertNotFound();
$this->assertFalse(Opportunity::findOrFail($native['id'])->isPrivateOfferPreparation());
$publisher = User::factory()->create();
app(PortalProfileAdministration::class)->insertGrant((string) Str::uuid(), User::factory()->create(), [
'user_id' => $publisher->id, 'role' => 'publisher', 'organization_id' => null,
'opportunity_types' => ['business_sale'], 'source' => 'synthetic-test-fixture',
]);
$this->assertTrue(app(OpportunityPromotion::class)->allowed($publisher, Opportunity::findOrFail($native['id'])));
$this->assertSame(1, Opportunity::count());
}
public function test_existing_contacts_registration_still_requires_fresh_mfa_and_submission_rechecks_contact(): void
{
$actor = $this->promoter();
$saved = $this->actingAs($actor)->postJson(self::BASE.'/rascunhos', $this->payload())->assertCreated()->json('record');
$data = ['etag' => $saved['etag'], 'full_name' => 'Contacto de teste QA', 'email' => 'private-offer@example.test', 'phone' => '+258840000001'];
$url = '/minhas-oportunidades/'.$saved['id'].'/contacts';
$this->postJson($url, $data)->assertForbidden();
$actor->forceFill(['two_factor_secret' => Crypt::encryptString('SYNTHETIC-QA'), 'two_factor_confirmed_at' => now()])->save();
$this->withSession(['portal.mfa_actor' => $actor->id, 'portal.mfa_at' => time()]);
$contact = $this->postJson($url, $data)->assertOk()->json('contact');
$this->postJson($url, $data)->assertOk()->assertJsonPath('contact.id', $contact['id']);
$payload = $this->payload();
$payload['private_offer']['contact_id'] = $contact['id'];
$saved = $this->postJson(self::BASE.'/'.$saved['id'].'/guardar', ['etag' => $saved['etag'], 'private_offer' => $payload['private_offer'], 'reason' => $payload['reason']])->assertOk()->json('record');
DB::table('contacts')->where('id', $contact['id'])->update(['deleted_at' => now()]);
$this->postJson(self::BASE.'/'.$saved['id'].'/submeter', ['etag' => $saved['etag'], 'confirm' => true])->assertUnprocessable()->assertJsonValidationErrors('private_offer.contact_id');
$this->assertSame('draft', Opportunity::findOrFail($saved['id'])->workflow_context['private_offer_preparation']['state']);
}
public function test_model_cannot_publish_or_remove_private_phase_marker(): void
{
$actor = $this->promoter();
$saved = $this->actingAs($actor)->postJson(self::BASE.'/rascunhos', $this->payload())->assertCreated()->json('record');
foreach ([['status' => 'published', 'published_at' => now()], ['workflow_context' => []]] as $change) {
try {
Opportunity::findOrFail($saved['id'])->forceFill($change)->save();
$this->fail('A preparação privada foi exposta ou convertida.');
} catch (HttpException $exception) {
$this->assertSame(403, $exception->getStatusCode());
}
}
$this->assertDatabaseHas('opportunities', ['id' => $saved['id'], 'status' => 'draft', 'published_at' => null]);
}
public function test_location_uses_existing_hierarchy_and_invalid_selection_never_saves(): void
{
$actor = $this->promoter();
$ids = [];
foreach (['country', 'province', 'district'] as $index => $type) {
$ids[$index] = (string) Str::uuid();
DB::table('geo_locations')->insert(['id' => $ids[$index], 'code' => $ids[$index], 'name' => 'QA '.$type,
'location_type' => $type, 'parent_id' => $index ? $ids[$index - 1] : null, 'data_classification' => 'internal']);
}
$payload = [...$this->payload(), 'province_id' => $ids[1], 'district_id' => $ids[2]];
$saved = $this->actingAs($actor)->postJson(self::BASE.'/rascunhos', $payload)->assertCreated()->json('record');
$this->getJson(self::BASE.'/'.$saved['id'])->assertOk()->assertJsonPath('record.territory.0.id', $ids[1])->assertJsonPath('record.territory.1.id', $ids[2]);
$this->postJson(self::BASE.'/'.$saved['id'].'/guardar', ['etag' => $saved['etag'], 'province_id' => $ids[2], 'district_id' => $ids[1], 'reason' => $payload['reason']])->assertUnprocessable();
$this->assertDatabaseHas('opportunities', ['id' => $saved['id'], 'primary_territory_id' => $ids[2]]);
}
public function test_full_html_uses_real_compiled_private_page_entries(): void
{
$actor = $this->promoter();
$directory = getenv('EDITORIAL_QA_BUILD_DIRECTORY') ?: 'build';
$manifest = json_decode(file_get_contents(public_path($directory.'/manifest.json')), true, 512, JSON_THROW_ON_ERROR);
$this->app->instance(Vite::class, (new Vite)->useBuildDirectory($directory)->useHotFile(storage_path('phpunit-offers-no-hot')));
$this->actingAs($actor);
foreach (['offers/preparation-index' => self::BASE, 'offers/preparation' => self::BASE.'/criar'] as $page => $url) {
$key = 'resources/js/pages/'.$page.'.tsx';
$this->assertArrayHasKey($key, $manifest);
$this->get($url, ['Accept' => 'text/html'])->assertOk()->assertHeader('Cache-Control', 'no-store, private')->assertSee($manifest[$key]['file'], false);
}
}
public function test_organization_representation_is_rechecked_and_other_organizations_are_not_assignable(): void
{
$organization = (string) Str::uuid();
DB::table('organizations')->insert(['id' => $organization, 'name' => 'Organização sintética QA', 'organization_type' => 'private']);
$actor = $this->promoter('business_sale', $organization);
$membership = (string) Str::uuid();
DB::table('memberships')->insert(['id' => $membership, 'user_id' => $actor->id, 'organization_id' => $organization, 'role' => 'officer']);
$saved = $this->actingAs($actor)->postJson(self::BASE.'/rascunhos', [...$this->payload(), 'organization_id' => $organization])->assertCreated()->json('record');
$this->postJson(self::BASE.'/'.$saved['id'].'/guardar', ['etag' => $saved['etag'], 'organization_id' => null, 'reason' => $this->payload()['reason']])->assertForbidden();
DB::table('memberships')->where('id', $membership)->update(['deleted_at' => now()]);
$this->getJson(self::BASE.'/'.$saved['id'])->assertForbidden();
$this->postJson(self::BASE.'/'.$saved['id'].'/guardar', ['etag' => $saved['etag'], 'reason' => $this->payload()['reason']])->assertForbidden();
$this->assertDatabaseHas('opportunities', ['id' => $saved['id'], 'organization_id' => $organization]);
}
public function test_incomplete_price_is_preserved_but_cannot_be_submitted_and_invalid_structures_return_validation_errors(): void
{
$actor = $this->promoter();
$payload = $this->payload();
$payload['private_offer'] = ['price_mode' => 'amount', 'amount' => null, 'currency' => null, 'contact_id' => $this->contact($actor)];
$saved = $this->actingAs($actor)->postJson(self::BASE.'/rascunhos', $payload)->assertCreated()->json('record');
$this->getJson(self::BASE.'/'.$saved['id'])->assertJsonPath('record.values.private_offer.price_mode', 'amount');
$this->postJson(self::BASE.'/'.$saved['id'].'/submeter', ['etag' => $saved['etag'], 'confirm' => true])->assertUnprocessable()->assertJsonValidationErrors(['private_offer.amount', 'private_offer.currency']);
$this->postJson(self::BASE.'/rascunhos', [...$this->payload(), 'private_offer' => 'invalid'])->assertUnprocessable();
$this->postJson(self::BASE.'/'.$saved['id'].'/guardar', ['etag' => $saved['etag'], 'opportunity_category' => null, 'reason' => $payload['reason']])->assertUnprocessable();
$this->assertSame(1, Opportunity::count());
}
}