Files
Investidor-V2/tests/Feature/InvestmentPreparationWorkspaceTest.php
T

303 lines
26 KiB
PHP

<?php
namespace Tests\Feature;
use App\Models\EconomicCluster;
use App\Models\InvestmentDossier;
use App\Models\Opportunity;
use App\Models\User;
use App\Services\ClusterCompetitiveness;
use App\Services\CorridorAgribusinessOpportunities;
use App\Services\InvestmentCorridorContext;
use App\Services\InvestmentPreparationWorkspace;
use App\Services\PrivateInvestmentPreparation;
use Illuminate\Foundation\Testing\DatabaseTransactions;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Storage;
use Illuminate\Support\Str;
use Tests\TestCase;
class InvestmentPreparationWorkspaceTest extends TestCase
{
use DatabaseTransactions;
private User $owner;
private string $caseId;
private array $documents = [];
protected function setUp(): void
{
parent::setUp();
$this->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']);
}
}