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()); } }