assertTrue(IsolatedQaDatabase::allowsSchemaChanges()); if (! Schema::hasColumn('portal_editorial_editions', 'dossier_id')) { (require database_path('migrations/2026_11_13_230000_extend_editorial_editions_to_portfolio.php'))->up(); } $this->withoutVite(); [$this->author, $this->reviewer] = User::factory()->count(2)->create()->all(); config(['portal.managers' => [$this->author->email, $this->reviewer->email], 'portal.profile_access_enabled' => false, 'editorial_workflow.enabled' => true, 'editorial_workflow.portfolio' => true]); $this->makeDossier('project'); } private function makeDossier(string $kind): void { $model = match ($kind) { 'project' => Project::class, 'cluster' => EconomicCluster::class, 'corridor' => DevelopmentCorridor::class, }; $entity = $model::create(['code' => 'ED-QA-'.bin2hex(random_bytes(6)), 'name' => 'Contexto sintético', 'status' => 'active', ...($kind === 'cluster' ? ['sector' => 'Energia'] : [])]); $this->dossier = InvestmentDossier::create([$kind.'_id' => $entity->id, 'title' => 'Ficha pública sintética '.$kind, 'source' => 'Fonte sintética QA', 'content' => ['description' => 'Texto público original'], 'version_no' => 1, 'publication_status' => 'published', 'approval_basis' => 'document_owner', 'approval_evidence' => 'Publicação sintética aceite em QA', 'published_at' => now()]); } private function edit(string $text = 'Alteração privada deliberada', int $status = 302, ?int $expected = null): ?\stdClass { $this->actingAsWithMfa($this->author); $record = app(PortalEditorialWorkflow::class)->workingPortfolio($this->dossier->fresh()); $this->put('/management/portfolio/'.$record->id, ['version_no' => $expected ?? $record->version_no, 'title' => 'Novo título privado', 'source' => 'Fonte sintética revista', 'content' => ['description' => $text]])->assertStatus($status); return app(PortalEditorialWorkflow::class)->latestPortfolio($record->id); } private function action(User $actor, \stdClass $edition, string $action, int $status = 200, array $extra = []): ?\stdClass { $this->actingAsWithMfa($actor); $this->postJson('/management/editorial/portfolio/'.$this->dossier->id.'/editions/'.$edition->id, ['version' => $edition->version, 'action' => $action, 'reason' => 'Decisão fundamentada de demonstração em QA', ...$extra])->assertStatus($status); return DB::table('portal_editorial_editions')->where('id', $edition->id)->first(); } private function approve(\stdClass $edition): \stdClass { $edition = $this->action($this->author, $edition, 'submit', 200, ['assigned_to' => $this->reviewer->id]); $edition = $this->action($this->reviewer, $edition, 'start_review'); return $this->action($this->reviewer, $edition, 'approve'); } public function test_public_project_cluster_and_corridor_fiches_survive_private_edit_review_and_approval(): void { foreach (['project', 'cluster', 'corridor'] as $kind) { if ($kind !== 'project') { $this->makeDossier($kind); } $before = $this->dossier->fresh()->getAttributes(); $edition = $this->edit(); $this->assertSame($before, $this->dossier->fresh()->getAttributes()); $this->get('/carteira/'.$this->dossier->id)->assertOk()->assertInertia(fn (Assert $p) => $p->where('record.content.description', 'Texto público original')); $this->get('/management/portfolio/'.$this->dossier->id)->assertOk()->assertHeader('Cache-Control', 'no-store, private')->assertInertia(fn (Assert $p) => $p->where('record.content.description', 'Alteração privada deliberada')->where('editorialEnabled', true)); $this->edit('Edição obsoleta', 409, 1); $edition = $this->approve($edition); $this->assertSame($before, $this->dossier->fresh()->getAttributes()); $this->action($this->author, $edition, 'publish', 403); $this->action($this->reviewer, $edition, 'publish'); $this->get('/carteira/'.$this->dossier->id)->assertInertia(fn (Assert $p) => $p->where('record.content.description', 'Alteração privada deliberada')); $this->assertSame($this->dossier->id, $this->dossier->fresh()->id); $this->assertSame($this->reviewer->id, $this->dossier->fresh()->reviewer_id); if ($kind === 'corridor') { $published = app(PortalEditorialWorkflow::class)->latestPortfolio($this->dossier->id); $this->action($this->reviewer, $published, 'archive'); $this->get('/carteira/'.$this->dossier->id)->assertNotFound(); } } } public function test_changed_context_and_edits_reset_decisions_and_old_publication_remains(): void { $edition = $this->approve($this->edit()); Project::whereKey($this->dossier->project_id)->update(['name' => 'Contexto revisto']); $this->getJson('/management/editorial/portfolio/'.$this->dossier->id)->assertJsonPath('edition.fresh', false); $this->action($this->reviewer, $edition, 'publish', 409); $new = $this->edit('Nova edição após alteração do contexto'); $this->assertNull($new->approval); $this->assertNull($new->legal_validation); $this->action($this->reviewer, $edition, 'publish', 409); $this->get('/carteira/'.$this->dossier->id)->assertInertia(fn (Assert $p) => $p->where('record.content.description', 'Texto público original')); $this->getJson('/management/editorial/portfolio/'.$this->dossier->id.'?edition='.$edition->id)->assertJsonPath('current', false)->assertJsonPath('edition.id', $edition->id); } public function test_permissions_api_notice_scope_and_legacy_bypass_fail_closed(): void { $edition = $this->edit(); $this->post('/management/portfolio/'.$this->dossier->id.'/publish', ['version_no' => 1, 'approval_evidence' => 'Tentativa legada sintética'])->assertConflict(); $edition = $this->action($this->author, $edition, 'submit', 200, ['assigned_to' => $this->reviewer->id]); $this->actingAsWithMfa($this->reviewer); $this->getJson('/management/editorial')->assertOk()->assertJsonPath('portfolioUrl', '/management/portfolio')->assertJsonPath('portfolioPending', 1) ->assertJsonPath('notices.0.href', '/management/editorial/portfolio/'.$this->dossier->id.'?edition='.$edition->id); $this->get('/gestao-conteudos')->assertInertia(fn (Assert $p) => $p->where('access.portfolio.index', '/management/portfolio')); $other = User::factory()->create(); $this->actingAs($other)->getJson('/management/editorial/portfolio/'.$this->dossier->id)->assertForbidden(); $this->action($other, $edition, 'start_review', 403); $this->actingAsWithMfa($this->reviewer); session()->forget('portal.mfa_at'); $this->postJson('/management/editorial/portfolio/'.$this->dossier->id.'/editions/'.$edition->id, ['version' => $edition->version, 'action' => 'start_review', 'reason' => 'Motivo sintético QA'])->assertForbidden(); config(['editorial_workflow.portfolio' => false]); $this->actingAsWithMfa($this->author); $this->put('/management/portfolio/'.$this->dossier->id, ['version_no' => 1, 'title' => 'Não escrever', 'source' => 'Fonte QA', 'content' => ['description' => 'Não escrever']])->assertStatus(503); $this->get('/carteira/'.$this->dossier->id)->assertOk(); $this->actingAsWithMfa($this->reviewer); $this->getJson('/management/editorial')->assertJsonCount(0, 'notices'); } public function test_revoked_reviewer_and_foreign_edition_cannot_publish_and_private_archive_preserves_public(): void { $firstId = $this->dossier->id; $edition = $this->approve($this->edit()); $this->makeDossier('cluster'); $this->action($this->reviewer, $edition, 'publish', 404); $this->getJson('/management/editorial/portfolio/'.$this->dossier->id.'?edition='.$edition->id)->assertNotFound(); $this->dossier = InvestmentDossier::findOrFail($firstId); config(['portal.managers' => [$this->author->email]]); // The author cannot publish, and a different authorised publisher must recheck the old reviewer. $publisher = User::factory()->create(); config(['portal.managers' => [$this->author->email, $publisher->email]]); $this->action($publisher, $edition, 'publish', 403); $this->action($this->author, $edition, 'archive'); $this->get('/carteira/'.$this->dossier->id)->assertOk(); $this->assertSame('Texto público original', $this->dossier->fresh()->content['description']); } public function test_every_private_contributor_is_excluded_and_portfolio_history_cannot_be_removed(): void { $this->edit(); $this->actingAsWithMfa($this->reviewer); $this->put('/management/portfolio/'.$this->dossier->id, ['version_no' => 2, 'title' => 'Colaboração privada', 'source' => 'Fonte sintética', 'content' => ['description' => 'Revisão deliberada pelo colaborador']])->assertRedirect(); $edition = app(PortalEditorialWorkflow::class)->latestPortfolio($this->dossier->id); $edition = $this->action($this->author, $edition, 'submit'); $edition = $this->action($this->reviewer, $edition, 'start_review'); $this->action($this->reviewer, $edition, 'approve', 403); $this->action($this->author, $edition, 'approve', 403); Project::whereKey($this->dossier->project_id)->update(['name' => 'Contexto alterado; é necessária correcção']); $this->action($this->reviewer, $edition, 'return'); $this->get('/management/portfolio/'.$this->dossier->id)->assertInertia(fn (Assert $p) => $p->where('record.content.description', 'Revisão deliberada pelo colaborador')); try { (require database_path('migrations/2026_11_13_230000_extend_editorial_editions_to_portfolio.php'))->down(); $this->fail('Histórico não vazio não pode ser eliminado.'); } catch (\RuntimeException $exception) { $this->assertStringContainsString('Preserve portfolio', $exception->getMessage()); } $this->assertTrue(Schema::hasColumn('portal_editorial_editions', 'dossier_id')); $this->assertSame(3, DB::table('portal_editorial_editions')->where('dossier_id', $this->dossier->id)->count()); } }