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_full_canonical_summary_survives_the_editor_echo_without_breaking_the_legacy_limit(): void { [$id, $promoter, , , $payload] = $this->fixture(); $payload['executive_summary'] = trim(str_repeat('Resumo canónico completo sintético QA. ', 30)); $this->assertGreaterThan(500, mb_strlen($payload['executive_summary'])); $this->loginFixture($promoter)->putJson('/minhas-oportunidades/'.$id.'/draft', [ 'etag' => app(OpportunityManagement::class)->etag(Opportunity::findOrFail($id)), 'canonical' => $payload, 'summary' => $payload['executive_summary'], 'reason' => self::REASON, ])->assertOk(); $record = Opportunity::findOrFail($id); $this->assertSame($payload['executive_summary'], app(OpportunityManagement::class)->values($record)['canonical']['executive_summary']); $this->assertLessThanOrEqual(500, mb_strlen($record->summary)); $this->assertStringEndsWith('…', $record->summary); $this->loginFixture($promoter)->putJson('/minhas-oportunidades/'.$id.'/draft', [ 'etag' => app(OpportunityManagement::class)->etag($record), 'summary' => $payload['executive_summary'], 'reason' => self::REASON, ])->assertUnprocessable()->assertJsonValidationErrors('summary'); } public function test_environmental_editor_reuses_canonical_fields_and_preserves_incomplete_drafts(): void { $service = app(OpportunityCanonicalContract::class); $schema = $service->schema(true); $this->assertSame('#/$defs/environmentalSocialCreate', $schema['$defs']['opportunityCreate']['properties']['environmental_social']['$ref']); $this->assertArrayHasKey('assessment_status', $schema['$defs']['environmentalSocialCreate']['properties']); $this->assertArrayHasKey('environmental_category', $schema['$defs']['environmentalSocialCreate']['properties']); $incomplete = ['environmental_social' => ['assessment_status' => 'Pending QA']]; $this->assertSame($incomplete, $service->draft($incomplete)); $this->assertSame(['assessment_status', 'environmental_category'], $service->schema()['$defs']['environmentalSocialCreate']['required']); } public function test_editorial_change_retains_signed_reviews_and_price_reopens_only_property_review(): void { [$id, $promoter, $validator, , $payload, $document] = $this->fixture(); $this->action($promoter, $id, 'submit'); 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]]); } $this->action($validator, $id, 'approve'); $original = Opportunity::findOrFail($id)->workflow_context['reviews']; $this->action($promoter, $id, 'request_revision'); $payload['official_title'] .= ' — correção editorial QA'; $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'); $context = Opportunity::findOrFail($id)->workflow_context; $this->assertSame([], $context['materiality']['reopened']); foreach ($original as $gate => $review) { $this->assertSame($review['hash'], $context['reviews'][$gate]['hash']); $this->assertSame($review['at'], $context['reviews'][$gate]['at']); $this->assertSame($review['actor_id'], $context['reviews'][$gate]['actor_id']); } $this->action($validator, $id, 'approve'); $this->action($promoter, $id, 'request_revision'); $payload['real_estate']['price'] += 25; $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'); $context = Opportunity::findOrFail($id)->workflow_context; $this->assertSame(['property_review'], $context['materiality']['reopened']); $this->assertSame(['registry_urban_review', 'building_compliance_review'], $context['materiality']['retained']); $this->action($validator, $id, 'approve', [], 422); $this->action($validator, $id, 'review_gate', ['gate' => 'property_review', 'gate_decision' => 'approved', 'evidence_document_ids' => [$document]]); $this->action($validator, $id, 'approve'); // A retained decision still requires a currently authorised independent reviewer. DB::table('portal_role_assignments')->where('user_id', $validator->id)->update(['revoked_at' => now()]); $response = $this->loginFixture($promoter)->getJson('/minhas-oportunidades/'.$id.'/response')->assertOk()->json(); $this->assertNotNull($response['materiality']); } public function test_geometry_order_is_material_and_object_key_order_is_not(): void { $materiality = app(OpportunityReviewMateriality::class); $a = ['payload' => ['opportunity_category' => 'real_estate'], 'resources' => ['locations' => [['payload' => ['geometry' => ['type' => 'LineString', 'coordinates' => [[1, 2], [3, 4], [5, 6]]]]]]]]; $b = $a; $b['resources']['locations'][0]['payload']['geometry']['coordinates'] = [[3, 4], [1, 2], [5, 6]]; $this->assertSame($materiality->hash($a, 'property_review', [], true), $materiality->hash($b, 'property_review', [], true)); $this->assertNotSame($materiality->hash($a, 'property_review'), $materiality->hash($b, 'property_review')); $this->assertSame(OpportunityCanonicalValue::hash(['price' => 100, 'currency' => 'MZN']), OpportunityCanonicalValue::hash(['currency' => 'MZN', 'price' => 100])); $this->assertSame($materiality->hash($a, 'land_legal_review', [], true), $materiality->hash($b, 'land_legal_review', [], true)); $this->assertNotSame($materiality->hash($a, 'land_legal_review'), $materiality->hash($b, 'land_legal_review')); } public function test_inline_identity_reorder_delete_and_completion_do_not_forge_creation_or_approval(): void { $actor = User::factory()->create(); $metadata = app(OpportunityCanonicalMetadata::class); $first = ['land_rights' => ['parcels' => [['cadastral_number' => 'A'], ['cadastral_number' => 'B']]]]; $m = $metadata->mutate([], $first, [], $actor, self::REASON); $a = $m['active']['land_rights.parcels.0']['id']; $b = $m['active']['land_rights.parcels.1']['id']; $second = ['land_rights' => ['parcels' => array_reverse($first['land_rights']['parcels'])]]; $m = $metadata->mutate($first, $second, $m, $actor, self::REASON); $this->assertSame($a, $m['active']['land_rights.parcels.1']['id']); $this->assertSame($b, $m['active']['land_rights.parcels.0']['id']); $third = ['land_rights' => ['parcels' => [$second['land_rights']['parcels'][0]]]]; $m = $metadata->mutate($second, $third, $m, $actor, self::REASON); $this->assertSame($a, $m['deleted'][$a]['id']); $this->assertSame($actor->id, $m['deleted'][$a]['deleted_by']); $this->assertSame(self::REASON, $m['deleted'][$a]['delete_reason']); $legacy = $metadata->mutate($first, $first, [], $actor, self::REASON); $this->assertNull($legacy['active']['land_rights']['created_by']); $this->assertSame('legacy_first_observation_not_creation', $legacy['active']['land_rights']['identity_source']); $completion = app(OpportunityCompleteness::class)->calculate(['opportunity_category' => 'real_estate']); $this->assertLessThan(100, $completion['percentage']); $this->assertNotContains('total_investment', $completion['missing_paths']); $this->assertNotContains('land_rights', $completion['missing_paths']); } public function test_private_resource_download_and_valid_signed_preview_require_organization_access(): void { [$id, $promoter, , , , $document] = $this->fixture(); $file = DB::table('opportunity_documents')->where('id', $document)->value('document_id'); $image = (string) Str::uuid(); DB::table('opportunity_images')->insert(['id' => $image, 'opportunity_id' => $id, 'document_id' => $file, 'created_by' => $promoter->id, 'version' => 1, 'payload' => json_encode(['title' => 'Synthetic permission fixture only', 'derivatives' => [['document_id' => $file, 'width' => 400, 'height' => 400]]])]); $original = '/minhas-oportunidades/'.$id.'/resources/documents/'.$document.'/download'; $preview = URL::temporarySignedRoute('promoter.opportunity-image-preview', now()->addMinutes(10), ['id' => $id, 'resourceId' => $image, 'documentId' => $file]); $this->loginFixture($promoter)->get($original)->assertOk(); $this->get($preview)->assertOk(); $organization = (string) Str::uuid(); DB::table('organizations')->insert(['id' => $organization, 'name' => 'Other synthetic QA organization', 'organization_type' => 'private']); $outsider = $this->actor('promoter', $organization); $this->loginFixture($outsider)->get($original)->assertForbidden(); $this->get($preview)->assertForbidden(); $this->assertDatabaseHas('documents', ['id' => $file, 'deleted_at' => null]); } public function test_read_response_preserves_ids_resources_permissions_and_does_not_invent_validation(): void { [$id, $promoter, $validator, , $payload, $document] = $this->fixture(); $url = '/minhas-oportunidades/'.$id.'/response'; $record = Opportunity::findOrFail($id); $before = $record->getRawOriginal(); $response = $this->loginFixture($promoter)->getJson($url)->assertOk() ->assertHeader('Cache-Control', 'no-store, private')->json(); $this->assertSame(OpportunityCanonicalResponse::VERSION, $response['adapter_version']); $this->assertSame($id, $response['data']['id']); $this->assertSame($record->reference, $response['data']['opportunity_code']); $this->assertSame($promoter->id, $response['data']['created_by']); $this->assertSame($promoter->id, $response['data']['updated_by']); $this->assertSame($record->created_at->toIso8601String(), $response['data']['created_at']); $this->assertNull($response['data']['deleted_by']); $this->assertSame([], $response['data']['mutation_metadata_gaps']); $this->assertSame($document, $response['data']['documents'][0]['id']); $this->assertSame($payload['locations'], $response['resource_references']['locations']); $this->assertNull($response['data']['approved_by']); $this->assertFalse($response['compatibility']['normative_response_complete']); $this->assertTrue($response['validation_result']['revalidated_on_read']); $this->assertArrayNotHasKey('storage_key', $response['data']['documents'][0]); $this->assertSame(100, $response['data']['completion_percentage']); $this->assertSame([], $response['data']['validation_result']['errors']); $this->assertSame($before, $record->fresh()->getRawOriginal()); $this->loginFixture($validator)->getJson($url)->assertOk(); $otherOrg = (string) Str::uuid(); DB::table('organizations')->insert(['id' => $otherOrg, 'name' => 'Other synthetic QA organization', 'organization_type' => 'private']); $other = $this->actor('promoter', $otherOrg); $this->loginFixture($other)->getJson($url)->assertForbidden(); $this->action($promoter, $id, 'submit'); $submitted = $this->loginFixture($promoter)->getJson($url)->assertOk()->json(); $this->assertSame($promoter->id, $submitted['data']['submitted_by']); $this->assertNotNull($submitted['data']['submitted_at']); $this->assertNull($submitted['data']['approved_by']); $this->assertNotNull($submitted['validation_result']['submission_hash']); $this->assertSame($payload['real_estate']['price'], $submitted['data']['real_estate']['price']); $this->assertSame($payload['real_estate']['title_document_id'], $submitted['data']['real_estate']['title_document_id']); $this->assertNotEmpty($submitted['data']['real_estate']['id']); $this->assertSame($promoter->id, $submitted['data']['real_estate']['created_by']); } public function test_cadastral_uniqueness_and_original_succession_modality_are_enforced_without_filling_drafts(): void { $fields = app(OpportunityCategoryFields::class); $record = new Opportunity; $record->id = 1; $payload = ['opportunity_category' => 'land_rights', 'land_rights' => ['duat_status' => 'none', 'operation_type' => 'investment_with_duat', 'exploitation_plan_required' => false, 'communities_affected' => false, 'parcels' => [['cadastral_number' => 'QA-001'], ['cadastral_number' => 'QA-001']]]]; $errors = []; $fields->validate($payload, $record, [], $errors); $this->assertSame(['canonical.land_rights.parcels.1.cadastral_number'], array_keys($errors)); foreach ([[null, null], ['QA-001', 'QA-002']] as $numbers) { $payload['land_rights']['parcels'] = array_map(fn ($number) => ['cadastral_number' => $number], $numbers); $errors = []; $fields->validate($payload, $record, [], $errors); $this->assertSame([], $errors); } $business = ['transaction_type' => 'succession', 'available_financial_years' => 1, 'financial_periods' => [['year' => (int) date('Y') - 1]], 'litigation_contingencies' => [['type' => 'none']], 'confidentiality_level' => 'internal', 'valuation' => ['value' => 100, 'date' => today()->toDateString()]]; $errors = []; $fields->validate(['opportunity_category' => 'business_sale', 'currency' => 'MZN', 'visibility' => 'internal', 'business_transaction' => $business], $record, [], $errors); $this->assertSame([], $errors); $errors = []; $fields->validate(['opportunity_category' => 'business_partnership', 'currency' => 'MZN', 'visibility' => 'internal', 'business_transaction' => $business], $record, [], $errors); $this->assertArrayHasKey('canonical.business_transaction.transaction_type', $errors); $this->assertSame(['land_rights' => ['parcels' => [['cadastral_number' => null]]]], app(OpportunityCanonicalContract::class)->draft(['land_rights' => ['parcels' => [['cadastral_number' => null]]]])); } 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'); app(PortalProfileAdministration::class)->insertGrant((string) Str::uuid(), User::factory()->create(), [ 'user_id' => $validator->id, 'role' => 'validator', 'opportunity_types' => ['real_estate'], 'responsibilities' => ['appeal_reviewer'], 'source' => 'synthetic-policy-test-fixture', ]); $validator->refresh(); $this->assertTrue(app(PortalProfileAccess::class)->has($validator, 'validator')); $this->action($validator, $id, 'appeal_reopen', [], 403); $this->assertTrue(Opportunity::findOrFail($id)->workflow_context['appeal']['pending']); $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_long_unicode_reason_completes_existing_task_and_preserves_full_decision(): void { [$id, $promoter, $validator] = $this->fixture(); $this->action($promoter, $id, 'submit'); $this->action($validator, $id, 'start_review'); $task = Opportunity::findOrFail($id)->workflow_context['review_task_id']; $reason = trim(str_repeat('Fundamentação íntegra com evidências e condições necessárias. ', 8)); $this->action($validator, $id, 'request_correction', ['reason' => $reason, 'correction_items' => ['canonical.executive_summary']]); $this->assertDatabaseHas('tasks', ['id' => $task, 'status' => 'done', 'outcome' => mb_substr($reason, 0, 79).'…']); $context = Opportunity::findOrFail($id)->workflow_context; $this->assertSame($reason, $context['last_decision']['reason']); $this->assertSame($reason, $context['events'][array_key_last($context['events'])]['reason']); $this->assertSame('needs_information', $context['working_state']); } public function test_resubmission_without_content_edit_creates_an_open_review_cycle_and_preserves_old_task(): void { [$id, $promoter, $validator, $publisher, $payload, $document] = $this->fixture(); $this->action($promoter, $id, 'submit'); $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]]); } $this->action($validator, $id, 'approve'); $this->action($publisher, $id, 'publish'); $before = Opportunity::findOrFail($id)->workflow_context; $task = $before['review_task_id']; $this->action($publisher, $id, 'suspend'); $this->action($promoter, $id, 'request_revision'); $this->action($promoter, $id, 'submit'); $this->action($validator, $id, 'start_review'); $after = Opportunity::findOrFail($id)->workflow_context; $this->assertSame($before['content_revision'], $after['content_revision']); $this->assertNotSame($task, $after['review_task_id']); $this->assertDatabaseHas('tasks', ['id' => $task, 'status' => 'done']); $this->assertDatabaseHas('tasks', ['id' => $after['review_task_id'], 'status' => 'in_progress', 'assignee_user_id' => $validator->id, 'due_at' => null]); $this->assertCount(2, DB::table('tasks')->where('source_system', 'opportunity-workflow')->where('task_type', 'opportunity_review')->get()); 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]]); } $this->action($validator, $id, 'approve'); $this->action($publisher, $id, 'publish'); $published = Opportunity::findOrFail($id)->workflow_context; $this->assertSame($before['publication']['hash'], $published['publication']['hash']); $this->assertNotSame($before['approval']['review_cycle'], $published['approval']['review_cycle']); } /** @return array */ public static function maturityStages(): array { $cases = []; foreach (OpportunityMaturity::STAGES as $stage => $label) { $cases[$stage] = [$stage, count($cases) + 1, $label]; } return $cases; } /** @param array $payload */ private function maturityLocation(array $payload): void { $location = json_decode(DB::table('opportunity_location_drafts')->where('id', $payload['locations'][0])->value('payload'), true); $country = (string) Str::uuid(); DB::table('geo_locations')->insert(['id' => $country, 'code' => $country, 'name' => 'Synthetic QA maturity country', 'location_type' => 'country', 'data_classification' => 'public', 'source_system' => 'cartografia-workbook', 'external_id' => '964a187ad32ea553b9e112cd159caa90b900050c42b7329594a75bcd798a96ff:'.$country]); DB::table('geo_locations')->where('id', $location['province_id'])->update(['parent_id' => $country]); foreach ([$location['province_id'], $location['district_id']] as $territory) { DB::table('geo_locations')->where('id', $territory)->update(['data_classification' => 'public', 'source_system' => 'cartografia-workbook', 'external_id' => '964a187ad32ea553b9e112cd159caa90b900050c42b7329594a75bcd798a96ff:'.$territory]); } } #[DataProvider('maturityStages')] public function test_clearing_an_optional_stage_does_not_reinterpret_its_published_number_as_historical(string $stage, int $position, string $label): void { [$id, $promoter, $validator, $publisher, $payload, $document] = $this->fixture(); $this->maturityLocation($payload); $payload['maturity_stage'] = $stage; if ($position >= 4) { $payload['financial_indicators'] = ['npv' => 123, 'irr' => 6, 'payback_years' => 5, 'horizon_years' => 10, 'discount_rate' => 7, 'currency' => 'MZN', 'scenario' => 'SYNTHETIC policy fixture, not a real financial model', 'model_document_id' => $document]; } foreach ([1, 2] as $cycle) { $this->loginFixture($promoter)->putJson('/minhas-oportunidades/'.$id.'/draft', [ 'etag' => app(OpportunityManagement::class)->etag(Opportunity::findOrFail($id)), 'canonical' => $payload, 'reason' => self::REASON, ])->assertOk(); $this->assertSame($payload['maturity_stage'], app(OpportunityManagement::class)->values(Opportunity::findOrFail($id))['canonical']['maturity_stage']); $this->action($promoter, $id, 'submit'); $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]]); } $this->action($validator, $id, 'approve'); $this->action($publisher, $id, 'publish'); $record = Opportunity::findOrFail($id); $this->assertSame($cycle === 1 ? $position : null, $record->maturity_level); $this->assertSame($cycle === 1 ? $label : null, $record->maturity_label); $this->assertEquals(['catalogue_version' => OpportunityMaturity::VERSION, 'stage' => $cycle === 1 ? $stage : null, 'level' => $cycle === 1 ? $position : null, 'label' => $cycle === 1 ? $label : null], data_get($record->workflow_context, 'publication.maturity')); $query = Opportunity::whereKey($id); OpportunityMaturity::constrainSearch($query, 'historical:'.$position); $this->assertSame(0, $query->count()); $query = Opportunity::whereKey($id); OpportunityMaturity::constrainSearch($query, 'stage:'.$stage); $this->assertSame($cycle === 1 ? 1 : 0, $query->count()); $this->assertSame(0, Opportunity::published()->whereKey($id)->count()); if ($cycle === 1) { $this->action($publisher, $id, 'suspend'); $this->action($promoter, $id, 'request_revision'); $payload['maturity_stage'] = null; } } $this->assertSame($stage, data_get($record->workflow_context, 'publication_history.0.maturity.stage')); } public function test_unknown_optional_maturity_stage_is_reported_before_review(): void { [$id, $promoter, , , $payload] = $this->fixture(); $payload['maturity_stage'] = 'SYNTHETIC-NOT-A-CATALOGUE-STAGE'; $this->loginFixture($promoter)->putJson('/minhas-oportunidades/'.$id.'/draft', [ 'etag' => app(OpportunityManagement::class)->etag(Opportunity::findOrFail($id)), 'canonical' => $payload, 'reason' => self::REASON, ])->assertUnprocessable()->assertJsonValidationErrors('canonical.maturity_stage'); $this->assertSame('draft', Opportunity::findOrFail($id)->status); $this->assertArrayNotHasKey('maturity_stage', app(OpportunityManagement::class)->values(Opportunity::findOrFail($id))['canonical']); } /** @return array */ public static function historicalMaturityLevels(): array { $cases = []; foreach (OpportunityMaturity::LEGACY_LABELS as $level => $label) { $cases[] = [$level, $label]; } return $cases; } #[DataProvider('historicalMaturityLevels')] public function test_publication_without_a_maturity_stage_retains_its_legacy_level(int $level, string $label): void { [$id, $promoter, $validator, $publisher, $payload, $document] = $this->fixture(); $this->maturityLocation($payload); Opportunity::findOrFail($id)->forceFill(['maturity_level' => $level, 'maturity_label' => $label])->save(); $this->action($promoter, $id, 'submit'); $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]]); } $this->action($validator, $id, 'approve'); $this->action($publisher, $id, 'publish'); $record = Opportunity::findOrFail($id); $this->assertSame($level, $record->maturity_level); $this->assertSame($label, $record->maturity_label); $query = Opportunity::whereKey($id); OpportunityMaturity::constrainSearch($query, 'historical:'.$level); $this->assertSame(1, $query->count()); $query = Opportunity::whereKey($id); OpportunityMaturity::constrainSearch($query, 'stage:'.array_keys(OpportunityMaturity::STAGES)[$level - 1]); $this->assertSame(0, $query->count()); $this->assertSame(0, Opportunity::published()->whereKey($id)->count()); } 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->assertSame([], $context['reviews']); $this->assertDatabaseHas('tasks', ['id' => $context['opinions'][$opinion]['task_id'], 'status' => 'done']); $this->action($validator, $id, 'approve', [], 422); } public function test_qa_modular_paths_use_distinct_semantics_and_existing_independent_workflow(): void { config(['portal.opportunity_modular_profiles_qa_enabled' => true]); foreach (['concession' => ['intention_call', 'awarded_right'], 'financing' => ['seeking_funding', 'funding_offer'], 'supply' => ['purchase_request', 'supply_offer'], 'other' => ['classified_other']] as $category => $modes) { foreach ($modes as $mode) { [$id, $promoter, $validator, $publisher, $payload, $document] = $this->fixture($category, $mode); $this->assertEquals($payload, app(OpportunityManagement::class)->values(Opportunity::findOrFail($id))['canonical']); if ($category === 'other') { $this->action($promoter, $id, 'submit', [], 422); config(['portal.opportunity_other_qa_subtypes.qa-example' => ['approved' => true, 'version' => 'synthetic-acceptance-v1', 'decision_reference' => 'Synthetic policy test only; not a real approved business subtype', 'gates' => ['technical_review', 'legal_territorial_review']]]); } $this->action($promoter, $id, 'submit'); $this->action($validator, $id, 'start_review'); $this->action($validator, $id, 'request_correction', ['correction_items' => ['canonical.executive_summary']]); $payload['executive_summary'] .= ' Correção QA recuperável.'; $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'); foreach (app(OpportunityCanonicalContract::class)->gates(Opportunity::findOrFail($id)) as $gate) { $this->action($validator, $id, 'review_gate', ['gate' => $gate, 'gate_decision' => 'approved', 'evidence_document_ids' => [$document]]); } $this->action($validator, $id, 'approve'); $this->action($publisher, $id, 'publish'); $response = $this->loginFixture($promoter)->getJson('/minhas-oportunidades/'.$id.'/response')->assertOk()->json(); $this->assertSame($mode, $response['data']['module_details']['mode']); $this->assertSame('restricted', $response['publication']['scope']); $this->assertSame(0, Opportunity::published()->whereKey($id)->count()); $this->assertLessThanOrEqual(500, mb_strlen(Opportunity::findOrFail($id)->summary)); $this->assertSame($payload['executive_summary'], $response['data']['executive_summary']); $this->assertNotEmpty(Opportunity::findOrFail($id)->workflow_context['approval']['gates']); if ($mode === 'intention_call') { $this->assertStringContainsString('não é direito atribuído', Opportunity::findOrFail($id)->summary); } } } } public function test_modular_semantic_failures_are_field_local_and_never_block_incomplete_draft_recovery(): void { config(['portal.opportunity_modular_profiles_qa_enabled' => true]); foreach ([['financing', 'seeking_funding', 'funding_sought', 999999], ['financing', 'funding_offer', 'total_investment', 100], ['concession', 'awarded_right', 'module_details.external_decision_document_id', null], ['supply', 'supply_offer', 'module_details.total_price', 99]] as [$category, $mode, $path, $value]) { [$id, $promoter, , , $payload] = $this->fixture($category, $mode); data_set($payload, $path, $value); $this->loginFixture($promoter)->putJson('/minhas-oportunidades/'.$id.'/draft', ['etag' => app(OpportunityManagement::class)->etag(Opportunity::findOrFail($id)), 'canonical' => $payload, 'reason' => self::REASON])->assertOk(); $this->getJson('/minhas-oportunidades/'.$id.'/response')->assertOk()->assertJsonPath('data.module_details.mode', $mode); $this->assertEquals($payload, app(OpportunityManagement::class)->values(Opportunity::findOrFail($id))['canonical']); $this->postJson('/minhas-oportunidades/'.$id.'/workflow', ['etag' => app(OpportunityManagement::class)->etag(Opportunity::findOrFail($id)), 'action' => 'submit', 'reason' => self::REASON])->assertUnprocessable()->assertJsonValidationErrors('canonical.'.$path); } $service = app(OpportunityCanonicalContract::class); $this->assertSame(['module_details' => ['mode' => 'funding_offer']], $service->draft(['module_details' => ['mode' => 'funding_offer']])); config(['portal.opportunity_modular_profiles_qa_enabled' => false]); $this->assertFalse(app(OpportunityModularProfiles::class)->enabled()); $this->assertArrayNotHasKey('module_details', $service->schema(true)['$defs']['opportunityCreate']['properties']); } public function test_new_entity_history_is_opt_in_and_does_not_fabricate_accounts(): void { config(['portal.opportunity_modular_profiles_qa_enabled' => true]); $schema = app(OpportunityCanonicalContract::class)->schema(); $this->assertSame(0, $schema['$defs']['businessTransactionCreate']['properties']['available_financial_years']['minimum']); $bt = ['transaction_type' => 'succession', 'available_financial_years' => 0, 'financial_periods' => [], 'history_status' => 'no_completed_years', 'history_declaration' => 'Entidade sintética recém-criada sem exercícios completos.', 'history_declaration_document_id' => 'qa-doc', 'projections' => ['scenario' => 'Projeção QA', 'source' => 'Plano QA', 'date' => '2026-10-10', 'document_id' => 'qa-doc'], 'litigation_contingencies' => [['type' => 'none']], 'confidentiality_level' => 'internal', 'valuation' => ['value' => 100, 'date' => '2026-10-10']]; $p = ['opportunity_category' => 'business_sale', 'module_profile_version' => OpportunityModularProfiles::HISTORY_VERSION, 'business_transaction' => $bt, 'visibility' => 'internal', 'currency' => 'MZN']; $errors = []; app(OpportunityCategoryFields::class)->validate($p, new Opportunity, ['documents' => ['qa-doc' => []]], $errors); $this->assertSame([], $errors); unset($p['module_profile_version']); $errors = []; app(OpportunityCategoryFields::class)->validate($p, new Opportunity, ['documents' => ['qa-doc' => []]], $errors); $this->assertArrayHasKey('canonical.module_profile_version', $errors); } /** @param array $payload * @return array */ private function modularPayload(string $category, string $mode, array $payload, string $document): array { unset($payload['real_estate']); $payload['opportunity_category'] = $category; $payload['module_profile_version'] = OpportunityModularProfiles::PROFILES[$category]; $m = ['mode' => $mode, 'object' => 'Objeto sintético QA', 'representation_document_id' => $document, 'object_document_id' => $document]; if ($category === 'concession') { $m += ['granting_entity' => 'Entidade sintética, não autoridade real', 'procedure_regime' => 'Procedimento sintético QA', 'scope' => 'Distrito QA', 'term_value' => 12, 'term_unit' => 'months', 'remuneration_obligations' => 'Obrigações sintéticas QA', 'participation_conditions' => 'Condições sintéticas QA', 'land_required' => false, 'construction_required' => false, 'disclosure_authorization_document_id' => $document]; if ($mode === 'awarded_right') { $m['external_decision_document_id'] = $document; } } if (in_array($category, ['financing', 'supply'], true)) { $m += ['conditions_status' => 'proposed', 'terms' => 'Condições indicativas QA, sem compromisso', 'scenario' => 'Cenário QA', 'source' => 'Fonte sintética QA', 'terms_date' => '2026-10-10']; } if ($category === 'financing') { $m += ['instrument' => 'debt', 'term_value' => 12, 'term_unit' => 'months', 'repayment_periodicity' => 'mensal', 'disbursement_calendar' => 'Calendário proposto QA']; if ($mode === 'seeking_funding') { $payload += ['total_investment' => 1000, 'investment_breakdown' => [['label' => 'QA', 'amount' => 1000, 'currency' => 'MZN']], 'secured_financing' => [], 'funding_sought' => 500]; $m += ['use_of_funds' => 'Preparação QA', 'financial_plan_document_id' => $document]; } else { $m += ['offered_capacity' => 100000, 'eligibility_criteria' => 'Critérios propostos QA', 'capacity_document_id' => $document]; } } if ($category === 'supply') { $m += ['quantity' => 10, 'unit' => 'unidade', 'destination' => 'Distrito QA', 'start_date' => '2026-10-11', 'end_date' => '2026-11-11', 'acceptance_criteria' => 'Critérios QA', 'unit_price' => 10, 'total_price' => 100, 'advance_or_guarantee' => false]; if ($mode === 'supply_offer') { $m['capacity_document_id'] = $document; } } if ($category === 'other') { $m += ['subtype' => 'qa-example', 'purpose' => 'Ensaio QA', 'recipients' => 'Destinatários sintéticos', 'expected_output' => 'Resultado sintético', 'risks' => 'Riscos declarados QA']; } $payload['module_details'] = $m; return $payload; } /** @return array{int,User,User,User,array,string} */ private function fixture(string $category = 'real_estate', string $mode = ''): 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-'.$org, '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-'.$org.'-'.$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' => $category, '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]; if ($category !== 'real_estate') { $payload = $this->modularPayload($category, $mode, $payload, $document); } $this->loginFixture($promoter)->putJson('/minhas-oportunidades/'.$id.'/draft', ['etag' => $draft['etag'], 'canonical' => $payload, 'reason' => self::REASON])->assertOk(); return [$id, $promoter, $validator, $publisher, $payload, $document]; } }