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