642 lines
50 KiB
PHP
642 lines
50 KiB
PHP
<?php
|
|
|
|
namespace Tests\Feature;
|
|
|
|
use App\Models\Opportunity;
|
|
use App\Models\User;
|
|
use App\Services\OpportunityCanonicalContract;
|
|
use App\Services\OpportunityCanonicalMetadata;
|
|
use App\Services\OpportunityCanonicalResponse;
|
|
use App\Services\OpportunityCanonicalValue;
|
|
use App\Services\OpportunityCategoryFields;
|
|
use App\Services\OpportunityCompleteness;
|
|
use App\Services\OpportunityManagement;
|
|
use App\Services\OpportunityModularProfiles;
|
|
use App\Services\OpportunityReviewMateriality;
|
|
use App\Services\PortalProfileAccess;
|
|
use App\Services\PortalProfileAdministration;
|
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
|
use Illuminate\Support\Facades\Crypt;
|
|
use Illuminate\Support\Facades\DB;
|
|
use Illuminate\Support\Facades\Storage;
|
|
use Illuminate\Support\Facades\URL;
|
|
use Illuminate\Support\Str;
|
|
use Illuminate\Validation\ValidationException;
|
|
use Inertia\Testing\AssertableInertia as Assert;
|
|
use Tests\TestCase;
|
|
|
|
class OpportunityCanonicalWorkflowTest extends TestCase
|
|
{
|
|
use RefreshDatabase;
|
|
|
|
private const REASON = 'Ensaio sintético de contratos e workflow independente em QA';
|
|
|
|
protected function setUp(): void
|
|
{
|
|
parent::setUp();
|
|
config(['portal.profile_access_enabled' => true, 'portal.opportunity_specialized_drafts_enabled' => true]);
|
|
}
|
|
|
|
/** @param list<string> $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<string,mixed> $extra
|
|
* @return array<string,mixed> */
|
|
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']);
|
|
}
|
|
|
|
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<string,mixed> $payload
|
|
* @return array<string,mixed> */
|
|
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,mixed>,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];
|
|
}
|
|
}
|