withoutVite(); $db = DB::selectOne('select current_database() as name,inet_server_port() as port'); $this->assertSame('simulator_fiscal_delta_20261021_test', $db->name); $this->assertSame(55441, (int) $db->port); config(['investor-path.enabled' => true, 'investor-path.private_preparation' => true, 'suitecrm.enabled' => false, 'investmoz_ai.enabled' => false]); Http::preventStrayRequests(); Storage::fake('local'); $this->owner = User::factory()->create(); $this->actingAs($this->owner); $this->caseId = $this->postJson('/investimentos', ['title' => 'SYNTHETIC private APIEX and SWOT QA', 'client_key' => (string) Str::uuid(), 'reuse_consent' => true])->assertCreated()->json('id'); foreach (PrivateInvestmentPreparation::DOCUMENTS as $key) { $id = (string) Str::uuid(); $bytes = 'SYNTHETIC QA ONLY '.$key; $sha = hash('sha256', $bytes); Storage::disk('local')->put('qa-preparation/'.$id, $bytes); DB::table('documents')->insert(['id' => $id, 'file_name' => $key.'.txt', 'media_type' => 'text/plain', 'storage_key' => 'qa-preparation/'.$id, 'sha256' => $sha, 'malware_scanned_sha256' => $sha, 'malware_scan_status' => 'clean', 'status' => 'active', 'uploaded_by' => $this->owner->id]); DB::table('desk_documents')->insert(['id' => (string) Str::uuid(), 'case_id' => $this->caseId, 'document_id' => $id, 'category' => 'support', 'source_reference' => 'SYNTHETIC fixture, not a real scan/submission', 'linked_by' => $this->owner->id]); $this->documents[$key] = $id; } $this->context(['investment_form' => 'direct', 'investment_preparation' => 'yes', 'investment_activity_scope' => 'general', 'implementation_mode' => 'domestic_entity', 'capital_origin' => 'national', 'site_tenure' => 'lease', 'project_concession' => 'no', 'significant_implications' => 'no', 'land_extension' => 'no', 'forest_concession' => 'no', 'total_investment' => '1000000', 'currency' => 'MZN', 'expected_start_date' => '2026-01-01', 'entity_stage' => 'being_formed'], $this->documents); } private function context(array $declarations, array $links = [], array $selection = []): void { $view = $this->getJson('/investimentos/'.$this->caseId.'/contexto')->assertOk()->json(); $data = ['version' => $view['case']['version'], 'selection' => $selection, 'declarations' => $declarations, 'document_links' => $links]; $plan = $this->postJson('/investimentos/'.$this->caseId.'/contexto/previa', $data)->assertOk()->json(); $this->postJson('/investimentos/'.$this->caseId.'/contexto/confirmar', [...$data, 'preview_hash' => $plan['preview_hash'], 'operation_key' => (string) Str::uuid(), 'confirm' => true])->assertOk(); } private function workspace(): array { return $this->getJson('/investimentos/'.$this->caseId.'/preparacao')->assertOk()->assertHeader('Cache-Control', 'no-store, private')->json(); } private function action(array $view, string $action, array $data = []): array { return $this->postJson('/investimentos/'.$this->caseId.'/preparacao', ['version' => $view['version'], 'operation_key' => (string) Str::uuid(), 'reason' => 'SYNTHETIC explicit review, not real external action', 'action' => $action, ...$data])->assertOk()->json(); } public function test_apiex_existing_dossier_requirements_review_zip_and_external_delivery_without_submission_or_approval(): void { $before = DB::table('journey_steps')->orderBy('id')->get()->toJson(); $view = $this->workspace(); $this->assertSame([], $view['api_issues']); $this->assertSame('preparing', $view['api_state']); $view = $this->action($view, 'apiex_review', ['fingerprint' => $view['api_fingerprint']]); $this->assertSame('ready', $view['api_state']); $this->get('/investimentos/'.$this->caseId.'/preparacao/previa')->assertOk()->assertSee('Não é formulário oficial', false); $export = (string) Str::uuid(); $body = ['version' => $view['version'], 'operation_key' => $export, 'reason' => 'SYNTHETIC download prepared package']; $download = $this->postJson('/investimentos/'.$this->caseId.'/preparacao/exportar', $body)->assertOk()->assertHeader('Content-Type', 'application/zip')->assertHeader('X-Export-Id', $export); $file = tempnam(sys_get_temp_dir(), 'qa-apiex-'); file_put_contents($file, $download->getContent()); $zip = new \ZipArchive; try { $this->assertTrue($zip->open($file)); $manifest = json_decode($zip->getFromName('manifesto.json'), true, flags: JSON_THROW_ON_ERROR); $this->assertSame($this->caseId, $manifest['case_id']); $this->assertSame($export, $manifest['export']['id']); $this->assertSame($this->owner->email, $manifest['reused']['investor']['email']); $this->assertSame($manifest['export']['prepared']['context'], $manifest['context']); $this->assertStringContainsString('Índice', $zip->getFromName('indice.html')); foreach ($manifest['export']['documents'] as $n => $doc) { $this->assertSame($doc['sha256'], hash('sha256', $zip->getFromName('anexos/'.sprintf('%02d-', $n + 1).$doc['file_name']))); } $zip->close(); } finally { unlink($file); } $this->postJson('/investimentos/'.$this->caseId.'/preparacao/exportar', $body)->assertOk(); $this->context(['project_summary' => 'SYNTHETIC later context, old archive must remain exact']); $oldPackage = $this->get('/investimentos/'.$this->caseId.'/preparacao/exportar/'.$export)->assertOk(); $this->assertSame(hash('sha256', $download->getContent()), hash('sha256', $oldPackage->getContent())); $view = $this->workspace(); $this->assertCount(1, $view['apiex']['exports']); $this->assertSame('preparing', $view['api_state']); $before = DB::table('journey_steps')->orderBy('id')->get()->toJson(); foreach (['delivery', 'clarification', 'response', 'decision'] as $kind) { $related = collect($view['apiex']['external'])->firstWhere('kind', 'clarification')['id'] ?? null; $view = $this->action($view, 'external', ['kind' => $kind, 'date' => now()->toDateString(), 'reference' => 'SYNTHETIC '.$kind, 'proof_document' => $this->documents['identity'], 'export_id' => $export, 'related_id' => $related, 'outcome' => $kind === 'decision' ? 'conditional' : null]); $this->assertSame($kind === 'decision' ? 'decision_received' : 'delivery_recorded', $view['api_state']); } $out = base_path('outputs/investment-preparation-20261110'); if (! is_dir($out)) { mkdir($out, 0700, true); } file_put_contents($out.'/apiex-package.zip', $download->getContent()); file_put_contents($out.'/apiex-recovered.json', json_encode([...$view, '_qa_archive' => ['initial_sha256' => hash('sha256', $download->getContent()), 'after_context_change_sha256' => hash('sha256', $oldPackage->getContent())]], JSON_THROW_ON_ERROR | JSON_UNESCAPED_UNICODE)); $this->assertSame($before, DB::table('journey_steps')->orderBy('id')->get()->toJson()); $this->assertCount(4, $view['apiex']['external']); Http::assertNothingSent(); DB::table('desk_cases')->where('id', $this->caseId)->update(['closed_at' => now()]); $this->get('/investimentos/'.$this->caseId.'/preparacao/exportar/'.$export)->assertOk()->assertHeader('Content-Type', 'application/zip'); $this->assertSame($this->documents['identity'], $view['apiex']['external'][0]['proof_snapshot']['document_id']); $this->assertStringNotContainsString('SYNTHETIC delivery', DB::table('audit_events')->where('source_system', 'investor-preparation')->get(['after_data'])->toJson()); } public function test_swot_context_evidence_four_dimensions_review_action_recovery_and_source_change_preserve_deliberate_edits(): void { $view = $this->workspace(); $rows = []; foreach (['strength', 'weakness', 'opportunity', 'threat'] as $dimension) { $rows[] = ['id' => (string) Str::uuid(), 'dimension' => $dimension, 'text' => 'SYNTHETIC deliberate '.$dimension, 'reference' => 'SYNTHETIC source, no economic conclusion', 'source_key' => null, 'date' => now()->toDateString(), 'classification' => 'unconfirmed']; } $view = $this->action($view, 'swot_save', ['draft' => ['object_key' => 'investment:'.$this->caseId, 'scope' => 'SYNTHETIC exact investment scope', 'rows' => $rows, 'source_fingerprint' => $view['swot_fingerprint']]]); $saved = $view['version']; $view = $this->action($view, 'swot_review', ['fingerprint' => $view['swot_fingerprint']]); $this->assertFalse($view['swot_review_required']); $view = $this->action($view, 'swot_action', ['conclusion_id' => $rows[3]['id'], 'kind' => 'risk', 'title' => 'SYNTHETIC investigate evidence gap']); $this->assertSame($this->caseId, $view['swot']['actions'][0]['case_id']); $this->assertStringContainsString('/investimentos/'.$this->caseId, $view['swot']['actions'][0]['href']); $continued = $this->getJson($view['swot']['actions'][0]['href'])->assertOk()->assertJsonPath('preparation_action.id', $view['swot']['actions'][0]['id'])->json(); $legacy = $this->postJson('/investimentos/'.$this->caseId.'/tarefas', ['version' => $continued['case']['version'], 'code' => 'S01', 'title' => 'SYNTHETIC independent existing stage task', 'due_at' => now()->addDay()->startOfMinute()->toISOString(), 'consent' => true])->assertCreated()->json('id'); $taskBody = ['version' => $continued['case']['version'], 'code' => 'S01', 'title' => $view['swot']['actions'][0]['title'], 'due_at' => now()->addDay()->startOfMinute()->toISOString(), 'consent' => true, 'preparation_action' => $view['swot']['actions'][0]['id'], 'opportunity' => $view['opportunity']]; $task = $this->postJson('/investimentos/'.$this->caseId.'/tarefas', $taskBody)->assertCreated()->json('id'); $this->postJson('/investimentos/'.$this->caseId.'/tarefas', $taskBody)->assertCreated()->assertJsonPath('id', $task); $this->assertSame('swot:'.$view['swot']['actions'][0]['id'], DB::table('tasks')->where('id', $task)->value('guidance_key')); $this->assertSame('SYNTHETIC independent existing stage task', DB::table('tasks')->where('id', $legacy)->value('title')); $this->assertSame($this->caseId, DB::table('tasks')->where('id', $task)->value('desk_case_id')); $this->assertSame($view['swot']['actions'][0]['title'], DB::table('tasks')->where('id', $task)->value('title')); $this->context(['project_summary' => 'SYNTHETIC changed source context']); $back = $this->workspace(); $this->assertTrue($back['swot_review_required']); $this->assertSame($rows, $back['swot']['rows']); $this->postJson('/investimentos/'.$this->caseId.'/preparacao', ['action' => 'swot_action', 'reason' => 'SYNTHETIC stale review', 'version' => $back['version'], 'operation_key' => (string) Str::uuid(), 'conclusion_id' => $rows[0]['id'], 'kind' => 'idea', 'title' => 'Should not advance'])->assertConflict(); $back = $this->action($back, 'swot_restore', ['revision' => $saved]); $this->assertTrue($back['swot_review_required']); $this->assertSame($rows, $back['swot']['rows']); $this->assertCount(1, $back['swot']['actions']); $this->assertGreaterThan($saved, $back['version']); file_put_contents(base_path('outputs/investment-preparation-20261110/swot-recovered.json'), json_encode([...$back, '_qa_native_task' => ['id' => $task, 'case_id' => DB::table('tasks')->where('id', $task)->value('desk_case_id'), 'title' => DB::table('tasks')->where('id', $task)->value('title'), 'origin_key' => DB::table('tasks')->where('id', $task)->value('guidance_key')]], JSON_THROW_ON_ERROR | JSON_UNESCAPED_UNICODE)); } public function test_access_stale_updates_replay_and_scan_changes_do_not_bypass_existing_dossier_guards(): void { $view = $this->workspace(); $body = ['version' => $view['version'], 'action' => 'apiex_save', 'operation_key' => (string) Str::uuid(), 'reason' => 'SYNTHETIC preservation', 'additional_documents' => [], 'note' => 'SYNTHETIC deliberate note']; $this->postJson('/investimentos/'.$this->caseId.'/preparacao', $body)->assertOk()->assertJsonPath('replayed', false); $this->postJson('/investimentos/'.$this->caseId.'/preparacao', $body)->assertOk()->assertJsonPath('replayed', true); $this->postJson('/investimentos/'.$this->caseId.'/preparacao', [...$body, 'note' => 'OTHER'])->assertConflict(); $this->postJson('/investimentos/'.$this->caseId.'/preparacao', [...$body, 'operation_key' => (string) Str::uuid()])->assertConflict(); $view = $this->workspace(); $view = $this->action($view, 'apiex_review', ['fingerprint' => $view['api_fingerprint']]); $export = (string) Str::uuid(); $this->postJson('/investimentos/'.$this->caseId.'/preparacao/exportar', ['version' => $view['version'], 'operation_key' => $export, 'reason' => 'SYNTHETIC archive security boundary'])->assertOk(); DB::table('documents')->where('id', $this->documents['identity'])->update(['malware_scan_status' => 'pending']); $blocked = $this->get('/investimentos/'.$this->caseId.'/preparacao/exportar/'.$export)->assertNotFound(); file_put_contents(base_path('outputs/investment-preparation-20261110/archive-security.json'), json_encode(['synthetic' => true, 'revoked_scan_cached_download_status' => $blocked->getStatusCode(), 'encrypted_archive_count' => count(Storage::disk('local')->allFiles('investor-preparations/'.$this->caseId))], JSON_THROW_ON_ERROR | JSON_PRETTY_PRINT)); $back = $this->workspace(); $this->assertNotSame('ready', $back['api_state']); $this->assertNotEmpty($back['api_issues']); $this->actingAs(User::factory()->create()); $this->getJson('/investimentos/'.$this->caseId.'/preparacao')->assertForbidden(); $this->actingAs($this->owner); DB::table('desk_cases')->where('id', $this->caseId)->update(['closed_at' => now()]); $this->postJson('/investimentos/'.$this->caseId.'/preparacao', [...$body, 'version' => $back['version'], 'operation_key' => (string) Str::uuid()])->assertConflict(); } public function test_swot_reuses_only_published_competitiveness_and_flags_a_changed_public_source_without_replacing_conclusions(): void { $cluster = EconomicCluster::create(['code' => 'SWOT-'.bin2hex(random_bytes(6)), 'name' => 'SYNTHETIC cluster SWOT QA', 'sector' => 'Energia', 'status' => 'planned']); $portfolio = InvestmentDossier::create(['cluster_id' => $cluster->id, 'title' => 'SYNTHETIC portfolio source', 'source' => 'SYNTHETIC QA only', 'content' => ['sector' => 'Energia'], 'publication_status' => 'published', 'approval_basis' => 'document_owner', 'approval_evidence' => 'SYNTHETIC QA fixture only', 'published_at' => now()]); $service = app(ClusterCompetitiveness::class); $draft = ['version' => 1, 'clusterId' => $cluster->id, 'sector' => 'Energia', 'region' => 'SYNTHETIC scope', 'forces' => []]; foreach (ClusterCompetitiveness::FORCES as $force) { $draft['forces'][$force] = ['pressure' => 'medium', 'evidence' => 'SYNTHETIC documented test only', 'source' => 'SYNTHETIC source', 'period' => '2026', 'rationale' => 'SYNTHETIC rationale', 'limitations' => 'No economic inference', 'opportunity' => 'Hypothesis only']; } $service->change($cluster, $this->owner->id, 0, 'saved', ['draft' => $draft]); $service->change($cluster, $this->owner->id, 1, 'approved', ['reason' => 'SYNTHETIC explicit QA source approval']); $this->context([], [], ['portfolio' => 'portfolio:'.$portfolio->id]); $view = $this->workspace(); $key = 'cluster:'.$cluster->id; $this->assertContains($key, array_column($view['objects'], 'key')); $rows = []; foreach (['strength', 'weakness', 'opportunity', 'threat'] as $dimension) { $rows[] = ['id' => (string) Str::uuid(), 'dimension' => $dimension, 'text' => 'SYNTHETIC deliberate '.$dimension, 'reference' => 'SYNTHETIC qualitative approved Competitiveness source', 'source_key' => $key, 'date' => now()->toDateString(), 'classification' => 'hypothesis']; } $view = $this->action($view, 'swot_save', ['draft' => ['object_key' => $key, 'scope' => 'SYNTHETIC same cluster and region', 'rows' => $rows, 'source_fingerprint' => $view['swot_fingerprint']]]); $view = $this->action($view, 'swot_review', ['fingerprint' => $view['swot_fingerprint']]); $approvedFingerprint = $view['swot_fingerprint']; $draft['forces']['rivalry']['evidence'] = 'PRIVATE-UNAPPROVED-SWOT-SOURCE'; $service->change($cluster, $this->owner->id, 2, 'saved', ['draft' => $draft]); $back = $this->workspace(); $this->assertFalse($back['swot_review_required']); $this->assertStringNotContainsString('PRIVATE-UNAPPROVED-SWOT-SOURCE', json_encode($back['sources'])); $privateFingerprint = $back['swot_fingerprint']; $service->change($cluster, $this->owner->id, 3, 'approved', ['reason' => 'SYNTHETIC explicit new public QA source']); $back = $this->workspace(); $this->assertTrue($back['swot_review_required']); $this->assertSame($rows, $back['swot']['rows']); $changedFingerprint = $back['swot_fingerprint']; $manager = User::factory()->create(); config(['portal.managers' => [$manager->email], 'portal.profile_access_enabled' => false]); $this->actingAs($manager); $team = $this->workspace(); $this->assertTrue($team['can_review']); $this->assertFalse($team['can_write']); $team = $this->action($team, 'swot_review', ['fingerprint' => $team['swot_fingerprint']]); $this->assertFalse($team['swot_review_required']); $this->postJson('/investimentos/'.$this->caseId.'/preparacao', ['action' => 'apiex_save', 'version' => $team['version'], 'operation_key' => (string) Str::uuid(), 'reason' => 'SYNTHETIC forbidden team overwrite', 'note' => 'must not save', 'additional_documents' => []])->assertForbidden(); $portfolio->update(['publication_status' => 'draft']); $withdrawn = $this->workspace(); $this->assertTrue($withdrawn['swot_review_required']); $this->assertSame($rows, $withdrawn['swot']['rows']); $this->assertNotEmpty($withdrawn['swot']['sources_snapshot']); file_put_contents(base_path('outputs/investment-preparation-20261110/source-changes.json'), json_encode(['synthetic' => true, 'approved_fingerprint' => $approvedFingerprint, 'private_edit_fingerprint' => $privateFingerprint, 'new_public_fingerprint' => $changedFingerprint, 'withdrawal_review_required' => $withdrawn['swot_review_required'], 'conclusions_preserved' => $rows === $withdrawn['swot']['rows'], 'team_reviewed_without_owner_write' => $team['can_review'] && ! $team['can_write']], JSON_THROW_ON_ERROR | JSON_PRETTY_PRINT)); } public function test_opportunities_in_one_dossier_keep_independent_preparation_versions_and_cannot_reuse_another_export(): void { $entries = array_slice(app(CorridorAgribusinessOpportunities::class)->entries(), 0, 2); $records = Opportunity::whereIn('management_creation_key', array_column($entries, 'key'))->orderBy('id')->get(); $this->assertCount(2, $records); $sites = collect(app(InvestmentCorridorContext::class)->provinces('pemba-lichinga'))->pluck('id'); $before = $this->getJson('/investimentos/'.$this->caseId.'/contexto')->assertOk()->json(); $body = ['version' => $before['case']['version'], 'selection' => [], 'opportunity_choices' => [ ['source' => 'opportunity:'.$records[0]->id, 'territory' => 'territory:'.$sites[0], 'activities' => ['production', 'processing']], ['source' => 'opportunity:'.$records[1]->id, 'territory' => 'territory:'.$sites[1], 'activities' => ['trade']], ]]; $plan = $this->postJson('/investimentos/'.$this->caseId.'/contexto/previa', $body)->assertOk()->json(); $this->postJson('/investimentos/'.$this->caseId.'/contexto/confirmar', [...$body, 'preview_hash' => $plan['preview_hash'], 'operation_key' => (string) Str::uuid(), 'confirm' => true])->assertOk(); $this->getJson('/investimentos/'.$this->caseId.'/preparacao')->assertUnprocessable(); $first = 'opportunity:'.$records[0]->id; $second = 'opportunity:'.$records[1]->id; $a = $this->getJson('/investimentos/'.$this->caseId.'/preparacao?opportunity='.urlencode($first))->assertOk()->json(); $this->assertSame($first, $a['opportunity']); $this->postJson('/investimentos/'.$this->caseId.'/preparacao', ['version' => 0, 'opportunity' => $first, 'action' => 'apiex_save', 'operation_key' => (string) Str::uuid(), 'reason' => 'SYNTHETIC scope1 only', 'note' => 'SYNTHETIC deliberate scope1', 'additional_documents' => []])->assertOk()->assertJsonPath('version', 1); $b = $this->getJson('/investimentos/'.$this->caseId.'/preparacao?opportunity='.urlencode($second))->assertOk()->json(); $this->assertSame(0, $b['version']); $this->assertSame('', $b['apiex']['note']); $this->assertNotSame($a['context']['fields']['province_id'], $b['context']['fields']['province_id']); $this->assertStringContainsString(urlencode($second), $b['links']['context']); $this->postJson('/investimentos/'.$this->caseId.'/preparacao', ['version' => 0, 'opportunity' => $second, 'action' => 'external', 'operation_key' => (string) Str::uuid(), 'reason' => 'SYNTHETIC cross-scope refused', 'kind' => 'delivery', 'date' => now()->toDateString(), 'reference' => 'SYNTHETIC nonexistent other export', 'export_id' => (string) Str::uuid(), 'proof_document' => $this->documents['identity']])->assertUnprocessable(); $context = $this->getJson('/investimentos/'.$this->caseId.'/contexto')->assertOk()->json(); $keep = ['version' => $context['case']['version'], 'selection' => [], 'opportunity_choices' => [$body['opportunity_choices'][1]]]; $plan = $this->postJson('/investimentos/'.$this->caseId.'/contexto/previa', $keep)->assertOk()->json(); $this->postJson('/investimentos/'.$this->caseId.'/contexto/confirmar', [...$keep, 'preview_hash' => $plan['preview_hash'], 'operation_key' => (string) Str::uuid(), 'confirm' => true])->assertOk(); $retired = $this->getJson('/investimentos/'.$this->caseId.'/preparacao?opportunity='.urlencode($first))->assertOk()->json(); $this->assertTrue($retired['historical']); $this->assertFalse($retired['can_write']); $this->assertSame('SYNTHETIC deliberate scope1', $retired['apiex']['note']); $this->assertContains($first, array_column($retired['saved_scopes'], 'key')); file_put_contents(base_path('outputs/investment-preparation-20261110/opportunity-scopes.json'), json_encode(['synthetic' => true, 'first_scope' => $first, 'second_scope' => $second, 'second_version_unchanged' => $b['version'], 'retired_scope_readable' => $retired['historical'], 'retired_scope_writable' => $retired['can_write'], 'histories' => array_column($retired['saved_scopes'], 'key')], JSON_THROW_ON_ERROR | JSON_PRETTY_PRINT)); } public function test_history_paginates_past_twenty_five_without_replacing_working_data_or_external_records(): void { $service = app(InvestmentPreparationWorkspace::class); for ($version = 0; $version < 27; $version++) { $service->change($this->owner, $this->caseId, ['version' => $version, 'action' => 'apiex_save', 'operation_key' => (string) Str::uuid(), 'reason' => 'SYNTHETIC history boundary fixture through writer', 'note' => 'SYNTHETIC version'.($version + 1), 'additional_documents' => []]); } $first = $this->workspace(); $this->assertCount(25, $first['history']); $this->assertSame(3, $first['history_next']); $next = $this->getJson('/investimentos/'.$this->caseId.'/preparacao?history_before='.$first['history_next'])->assertOk()->json(); $this->assertSame([2, 1], array_column($next['history'], 'version')); $this->assertNull($next['history_next']); $this->assertSame(27, $next['version']); $this->assertSame('SYNTHETIC version27', $next['apiex']['note']); $last = $this->action($next, 'swot_restore', ['revision' => 1]); $this->assertSame(28, $last['version']); file_put_contents(base_path('outputs/investment-preparation-20261110/history-pages.json'), json_encode(['synthetic' => true, 'first_versions' => array_column($first['history'], 'version'), 'next_versions' => array_column($next['history'], 'version'), 'new_version_after_restore' => $last['version']], JSON_THROW_ON_ERROR | JSON_PRETTY_PRINT)); $this->assertSame('SYNTHETIC version27', $last['apiex']['note']); } }