Files
Investidor-V2/tests/Feature/OpportunityCanonicalWorkflowTest.php
T
nala 287e991486
tests / ci (push) Waiting to run
oportunidades 7
2026-10-13 11:40:29 +02:00

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];
}
}