true, 'portal.opportunity_specialized_drafts_enabled' => true]); } /** @param list $responsibilities */ private function actor(string $role, string $org, array $responsibilities = []): User { $user = User::factory()->create(); $user->forceFill(['two_factor_secret' => Crypt::encryptString('JBSWY3DPEHPK3PXP'), 'two_factor_confirmed_at' => now()])->save(); app(PortalProfileAdministration::class)->insertGrant((string) Str::uuid(), User::factory()->create(), [ 'user_id' => $user->id, 'role' => $role, 'organization_id' => $role === 'promoter' ? $org : null, 'opportunity_types' => OpportunityManagement::CATEGORIES, 'responsibilities' => $responsibilities, 'source' => 'synthetic-policy-test-fixture']); return $user; } private function loginFixture(User $user): static { // Policy fixture only. Real Fortify/ClamD evidence comes from the QA HTTP scenario. return $this->actingAs($user)->withSession(['portal.mfa_actor' => $user->id, 'portal.mfa_at' => time(), 'portal.access_revision' => (int) $user->portal_access_revision]); } /** @param array $extra * @return array */ private function action(User $user, int $id, string $action, array $extra = [], int $status = 200): array { $etag = app(OpportunityManagement::class)->etag(Opportunity::findOrFail($id)); if ($action === 'publish') { $extra = ['confirm_publication' => true, 'preview_hash' => Opportunity::findOrFail($id)->workflow_context['approval']['hash'] ?? str_repeat('0', 64), ...$extra]; } return $this->loginFixture($user)->postJson('/minhas-oportunidades/'.$id.'/workflow', ['etag' => $etag, 'action' => $action, 'reason' => self::REASON, ...$extra])->assertStatus($status)->json(); } public function test_incomplete_drafts_preserve_category_rules_and_block_client_certification(): void { $service = app(OpportunityCanonicalContract::class); foreach (OpportunityManagement::CATEGORIES as $category) { $this->assertSame(['opportunity_category' => $category], $service->draft(['opportunity_category' => $category])); } $this->assertSame(['real_estate' => ['transaction_type' => 'sale', 'valuation' => []]], $service->draft(['real_estate' => ['transaction_type' => 'sale', 'valuation' => []]])); $this->assertSame(['real_estate' => ['transaction_type' => null, 'land_area' => null, 'property_type' => null]], $service->draft(['real_estate' => ['transaction_type' => null, 'land_area' => null, 'property_type' => null]])); foreach ([['land_rights' => ['operation_type' => 'land_sale']], ['business_transaction' => ['percentage_offered' => 101]], ['real_estate' => ['land_area' => -1]], ['approved_by' => 99], ['evidences' => [['verified' => true]]]] as $invalid) { try { $service->draft($invalid); $this->fail('Invalid provided data or response metadata must be rejected.'); } catch (ValidationException $e) { $this->assertNotEmpty($e->errors()); } } $schema = $service->schema(); $this->assertNotEmpty($schema['$defs']['opportunityCreate']['allOf']); $this->assertNotEmpty($schema['$defs']['realEstateCreate']['allOf']); $this->assertNotEmpty($schema['$defs']['landRightsCreate']['allOf']); $this->assertNotEmpty($schema['$defs']['businessTransactionCreate']['allOf']); } public function test_correction_approval_publication_material_revision_and_suspension_preserve_authorization(): void { [$id, $promoter, $validator, $publisher, $payload, $document] = $this->fixture(); $this->action($promoter, $id, 'submit'); $this->action($validator, $id, 'approve', [], 422); $this->action($validator, $id, 'start_review'); $firstReview = Opportunity::findOrFail($id)->workflow_context['review_task_id']; $this->action($validator, $id, 'request_correction', ['correction_items' => ['canonical.executive_summary']]); $this->assertDatabaseHas('tasks', ['id' => $firstReview, 'status' => 'done']); $payload['executive_summary'] .= ' Corrigido em QA.'; // Primary is deliberately second: array order must not change the public territory. $primary = DB::table('opportunity_location_drafts')->where('id', $payload['locations'][0])->first(); $primaryData = json_decode($primary->payload, true); $secondaryId = (string) Str::uuid(); $secondaryDistrict = (string) Str::uuid(); DB::table('geo_locations')->insert(['id' => $secondaryDistrict, 'code' => $secondaryDistrict, 'name' => 'Non-primary QA district', 'location_type' => 'district', 'parent_id' => $primaryData['province_id'], 'data_classification' => 'internal']); DB::table('opportunity_location_drafts')->insert(['id' => $secondaryId, 'opportunity_id' => $id, 'version' => 1, 'created_by' => $promoter->id, 'payload' => json_encode([...$primaryData, 'district_id' => $secondaryDistrict, 'is_primary' => false])]); $payload['locations'] = [$secondaryId, $payload['locations'][0]]; $payload['location_mode'] = 'multiple'; $this->loginFixture($promoter)->putJson('/minhas-oportunidades/'.$id.'/draft', ['etag' => app(OpportunityManagement::class)->etag(Opportunity::findOrFail($id)), 'canonical' => $payload, 'reason' => self::REASON])->assertOk(); $this->action($promoter, $id, 'submit'); $this->assertArrayNotHasKey('assignment', Opportunity::findOrFail($id)->workflow_context); $this->action($validator, $id, 'start_review'); foreach (['property_review', 'registry_urban_review', 'building_compliance_review'] as $gate) { $this->action($validator, $id, 'review_gate', ['gate' => $gate, 'gate_decision' => 'approved', 'evidence_document_ids' => [$document]]); } app(PortalProfileAdministration::class)->insertGrant((string) Str::uuid(), User::factory()->create(), ['user_id' => $promoter->id, 'role' => 'validator', 'opportunity_types' => ['real_estate'], 'responsibilities' => ['technical_reviewer', 'legal_reviewer']]); $this->action($promoter, $id, 'approve', [], 403); $this->action($validator, $id, 'approve'); app(PortalProfileAdministration::class)->insertGrant((string) Str::uuid(), User::factory()->create(), ['user_id' => $validator->id, 'role' => 'publisher', 'opportunity_types' => ['real_estate']]); $this->action($validator, $id, 'publish', [], 403); $this->action($publisher, $id, 'assign_publication', ['assignee_user_id' => $publisher->id]); $otherPublisher = $this->actor('publisher', 'unused'); $this->action($otherPublisher, $id, 'publish', [], 403); $grants = DB::table('portal_role_assignments')->where('user_id', $validator->id)->where('role', 'validator')->whereNull('revoked_at')->pluck('id'); DB::table('portal_role_assignments')->whereIn('id', $grants)->update(['revoked_at' => now()]); $this->action($publisher, $id, 'publish', [], 403); DB::table('portal_role_assignments')->whereIn('id', $grants)->update(['revoked_at' => null]); $this->action($publisher, $id, 'publish'); $this->assertSame($primaryData['district_id'], Opportunity::findOrFail($id)->primary_territory_id); $publicTitle = Opportunity::findOrFail($id)->title; $this->assertSame(0, Opportunity::published()->count(), 'Internal publication must not leak to public consumers.'); $this->action($promoter, $id, 'request_revision'); $payload['official_title'] = 'Título materialmente alterado, ainda privado'; $this->loginFixture($promoter)->putJson('/minhas-oportunidades/'.$id.'/draft', ['etag' => app(OpportunityManagement::class)->etag(Opportunity::findOrFail($id)), 'canonical' => $payload, 'reason' => self::REASON])->assertOk(); $record = Opportunity::findOrFail($id); $this->assertSame('published', $record->status); $this->assertSame($publicTitle, $record->title); $this->assertArrayNotHasKey('approval', $record->workflow_context); $this->assertSame('needs_information', $record->workflow_context['working_state']); $this->get('/minhas-oportunidades?status=needs_information&q=materialmente')->assertInertia(fn (Assert $p) => $p->where('records.total', 1)->where('records.data.0.draft_title', 'Título materialmente alterado, ainda privado')->where('counts.needs_information', 1)); $this->action($publisher, $id, 'publish', [], 409); $this->action($publisher, $id, 'suspend'); $this->assertSame('suspended', Opportunity::findOrFail($id)->status); $this->assertDatabaseHas('tasks', ['source_system' => 'opportunity-workflow', 'task_type' => 'opportunity_review', 'assignee_user_id' => $validator->id, 'due_at' => null, 'status' => 'done']); $this->assertSame('clean', DB::table('documents')->where('id', DB::table('opportunity_documents')->where('id', $document)->value('document_id'))->value('malware_scan_status')); } public function test_invalid_binding_reason_and_original_integrity_cannot_be_bypassed(): void { [$id, $promoter, $validator, $publisher, $payload, $document] = $this->fixture(); $bad = $payload; $bad['real_estate']['price'] = null; $bad['documents'] = [(string) Str::uuid()]; $this->loginFixture($promoter)->putJson('/minhas-oportunidades/'.$id.'/draft', ['etag' => app(OpportunityManagement::class)->etag(Opportunity::findOrFail($id)), 'canonical' => $bad, 'reason' => self::REASON])->assertOk(); $this->action($promoter, $id, 'submit', [], 422); $this->loginFixture($promoter)->putJson('/minhas-oportunidades/'.$id.'/draft', ['etag' => app(OpportunityManagement::class)->etag(Opportunity::findOrFail($id)), 'canonical' => $payload, 'reason' => self::REASON])->assertOk(); $this->action($promoter, $id, 'submit'); $this->action($validator, $id, 'reject', ['reason' => 'curto'], 422); $this->action($validator, $id, 'reject'); $this->assertDatabaseHas('tasks', ['id' => Opportunity::findOrFail($id)->workflow_context['review_task_id'], 'status' => 'done']); $this->assertSame(self::REASON, Opportunity::findOrFail($id)->workflow_context['rejection']['reason']); $this->action($promoter, $id, 'appeal'); $this->action($validator, $id, 'appeal_reopen', [], 403); $appeal = $this->actor('validator', $promoter->id.'unused', ['appeal_reviewer']); $this->action($appeal, $id, 'appeal_reopen'); $this->assertFalse(Opportunity::findOrFail($id)->workflow_context['appeal']['pending']); $storageKey = DB::table('documents')->where('id', DB::table('opportunity_documents')->where('id', $document)->value('document_id'))->value('storage_key'); Storage::disk('local')->put($storageKey, 'Invalid ciphertext - synthetic QA only'); $this->action($promoter, $id, 'submit', [], 404); $this->loginFixture($promoter)->get('/minhas-oportunidades/'.$id.'/resources/documents/'.$document.'/download')->assertNotFound(); } public function test_semantic_errors_are_keyed_to_fields_and_incomplete_drafts_remain_recoverable(): void { [$id,$promoter,$validator,$publisher,$payload,$document] = $this->fixture(); foreach (['utilities', 'title_document_id', 'occupancy_document_id', 'valuation.value'] as $field) { $bad = $payload; if ($field === 'utilities') { $bad['real_estate']['utilities'] = []; } elseif ($field === 'title_document_id') { unset($bad['real_estate'][$field]); } elseif ($field === 'valuation.value') { $bad['real_estate']['valuation']['value'] = 0; } else { $bad['real_estate']['occupancy_status'] = 'tenant_occupied'; } $this->loginFixture($promoter)->putJson('/minhas-oportunidades/'.$id.'/draft', ['etag' => app(OpportunityManagement::class)->etag(Opportunity::findOrFail($id)), 'canonical' => $bad, 'reason' => self::REASON])->assertOk(); $this->get('/minhas-oportunidades/'.$id.'/edit')->assertInertia(fn (Assert $page) => $page->where('values.canonical', $bad)); $this->loginFixture($promoter)->postJson('/minhas-oportunidades/'.$id.'/workflow', ['etag' => app(OpportunityManagement::class)->etag(Opportunity::findOrFail($id)), 'action' => 'submit', 'reason' => self::REASON])->assertUnprocessable()->assertJsonValidationErrors('canonical.real_estate.'.$field); } } public function test_scoped_opinion_tasks_require_the_recipient_and_do_not_approve_a_gate(): void { [$id,$promoter,$validator,$publisher,$payload,$document] = $this->fixture(); $opinionReviewer = $this->actor('validator', 'unused', ['technical_reviewer']); $this->action($promoter, $id, 'submit'); $this->action($validator, $id, 'start_review'); $this->action($validator, $id, 'pending_opinion', ['gate' => 'property_review', 'recipient_user_id' => $opinionReviewer->id]); $context = Opportunity::findOrFail($id)->workflow_context; $opinion = array_key_first($context['opinions']); $this->action($validator, $id, 'approve', [], 422); $this->action($validator, $id, 'respond_opinion', ['opinion_id' => $opinion, 'gate_decision' => 'approved', 'evidence_document_ids' => [$document]], 403); $this->loginFixture($opinionReviewer)->get('/minhas-oportunidades?queue=assigned')->assertInertia(fn (Assert $p) => $p->where('records.total', 1)); $this->action($opinionReviewer, $id, 'respond_opinion', ['opinion_id' => $opinion, 'gate_decision' => 'approved', 'evidence_document_ids' => [$document]]); $context = Opportunity::findOrFail($id)->workflow_context; $this->assertSame('answered', $context['opinions'][$opinion]['status']); $this->assertArrayNotHasKey('reviews', $context); $this->assertDatabaseHas('tasks', ['id' => $context['opinions'][$opinion]['task_id'], 'status' => 'done']); $this->action($validator, $id, 'approve', [], 422); } /** @return array{int,User,User,User,array,string} */ private function fixture(): array { $org = (string) Str::uuid(); DB::table('organizations')->insert(['id' => $org, 'name' => 'Synthetic QA organization', 'organization_type' => 'private']); $promoter = $this->actor('promoter', $org); DB::table('memberships')->insert(['id' => (string) Str::uuid(), 'user_id' => $promoter->id, 'organization_id' => $org, 'role' => 'officer']); $validator = $this->actor('validator', $org, ['technical_reviewer', 'legal_reviewer', 'validation_coordinator']); $publisher = $this->actor('publisher', $org, ['publication_coordinator']); $version = (string) Str::uuid(); DB::table('reference_catalog_versions')->insert(['id' => $version, 'catalog_code' => 'qa-only', 'version_no' => 1, 'source_system' => 'synthetic-policy-fixture', 'source_version' => 'qa', 'source_reference' => 'Not official', 'status' => 'published']); $ids = []; $parent = null; foreach (range(1, 3) as $i) { $sector = DB::table('sectors')->insertGetId(['name' => 'QA '.$i, 'slug' => 'qa-'.$i, 'is_active' => true]); $id = (string) Str::uuid(); DB::table('economic_activities')->insert(['id' => $id, 'parent_id' => $parent, 'sector_id' => $sector, 'catalog_version_id' => $version, 'code' => 'qa-'.$i, 'label_pt_mz' => 'QA '.$i, 'status' => 'published', 'valid_from' => today()]); $ids[] = [$id, $sector]; $parent = $id; } $contact = (string) Str::uuid(); $investor = (string) Str::uuid(); DB::table('contacts')->insert(['id' => $contact, 'full_name' => 'Synthetic QA contact']); DB::table('investors')->insert(['id' => $investor, 'organization_id' => $org, 'legal_name' => 'Synthetic QA entity', 'investor_type' => 'institutional', 'status' => 'active']); DB::table('investor_contacts')->insert(['investor_id' => $investor, 'contact_id' => $contact]); $draft = $this->loginFixture($promoter)->postJson('/minhas-oportunidades/drafts', ['creation_key' => (string) Str::uuid(), 'organization_id' => $org, 'opportunity_category' => 'real_estate', 'reason' => self::REASON])->assertCreated()->json(); $id = $draft['id']; $location = (string) Str::uuid(); $provinceId = (string) Str::uuid(); $districtId = (string) Str::uuid(); DB::table('geo_locations')->insert([['id' => $provinceId, 'code' => $provinceId, 'name' => 'Synthetic QA province', 'location_type' => 'province', 'parent_id' => null, 'data_classification' => 'internal'], ['id' => $districtId, 'code' => $districtId, 'name' => 'Synthetic QA district', 'location_type' => 'district', 'parent_id' => $provinceId, 'data_classification' => 'internal']]); $province = DB::table('geo_locations')->where('id', $provinceId)->first(); $district = DB::table('geo_locations')->where('id', $districtId)->first(); DB::table('opportunity_location_drafts')->insert(['id' => $location, 'opportunity_id' => $id, 'version' => 1, 'created_by' => $promoter->id, 'payload' => json_encode(['country_code' => 'MZ', 'province_id' => $province->id, 'district_id' => $district->id, 'role' => 'headquarters', 'is_primary' => true])]); $document = (string) Str::uuid(); $file = (string) Str::uuid(); $bytes = 'Synthetic policy fixture, not a real scanner result.'; $key = 'opportunity-contract-test/'.$file; Storage::disk('local')->put($key, Crypt::encryptString($bytes)); DB::table('documents')->insert(['id' => $file, 'file_name' => 'qa-fixture.txt', 'storage_key' => $key, 'storage_encryption' => 'laravel', 'media_type' => 'text/plain', 'sha256' => hash('sha256', $bytes), 'status' => 'active', 'malware_scan_status' => 'clean', 'malware_engine' => 'synthetic-policy-fixture', 'malware_scanned_sha256' => hash('sha256', $bytes), 'uploaded_by' => $promoter->id]); DB::table('opportunity_documents')->insert(['id' => $document, 'opportunity_id' => $id, 'document_id' => $file, 'version' => 1, 'created_by' => $promoter->id, 'payload' => json_encode(['title' => 'QA', 'category' => 'qa', 'visibility' => 'internal'])]); $payload = ['opportunity_category' => 'real_estate', 'official_title' => 'Imóvel sintético para ensaio QA', 'promoter_id' => $org, 'promoter_type' => 'private', 'internal_contact_id' => $contact, 'sector_id' => $ids[0][1], 'subsector_id' => $ids[1][0], 'economic_activity_id' => $ids[2][0], 'location_mode' => 'single', 'geographic_scope' => 'district', 'locations' => [$location], 'executive_summary' => trim(str_repeat('Resumo sintético QA. ', 20)), 'demand_statement' => trim(str_repeat('Necessidade sintética QA. ', 6)), 'documents' => [$document], 'evidences' => [['title' => 'QA', 'evidence_type' => 'qa', 'document_id' => $document]], 'publication_languages' => [['language_code' => 'pt-MZ', 'title' => 'QA', 'summary' => 'QA']], 'visibility' => 'internal', 'currency' => 'MZN', 'truth_declaration' => true, 'real_estate' => ['transaction_type' => 'sale', 'property_type' => 'warehouse', 'construction_status' => 'completed', 'property_condition' => 'good', 'land_area' => 1000, 'built_area' => 500, 'area_unit' => 'sqm', 'utilities' => [['type' => 'water', 'status' => 'existing']], 'occupancy_status' => 'vacant', 'title_type' => 'QA', 'registration_number' => 'QA-'.$id, 'encumbrances' => [['type' => 'none']], 'valuation' => ['value' => 1000, 'date' => '2026-10-10', 'method' => 'qa', 'appraiser' => 'QA', 'document_id' => $document], 'price' => 1000, 'use_license_document_id' => $document]]; $authority = (string) Str::uuid(); DB::table('authorities')->insert(['id' => $authority, 'catalog_version_id' => $version, 'organization_id' => $org, 'code' => 'QA-'.$id, 'authority_type' => 'qa-synthetic', 'label_pt_mz' => 'Entidade sintética QA', 'source_reference' => 'Synthetic policy fixture; not official', 'valid_from' => '2026-01-01', 'status' => 'published']); $payload['real_estate']['utilities'] = array_map(fn ($type) => ['type' => $type, 'status' => 'existing'], ['electricity', 'water', 'sanitation', 'telecommunications', 'access']); $payload['real_estate'] += ['title_document_id' => $document, 'title_holder_name' => 'Synthetic QA organization', 'issuing_authority_id' => $authority]; $this->loginFixture($promoter)->putJson('/minhas-oportunidades/'.$id.'/draft', ['etag' => $draft['etag'], 'canonical' => $payload, 'reason' => self::REASON])->assertOk(); return [$id, $promoter, $validator, $publisher, $payload, $document]; } }