assertTrue(IsolatedQaDatabase::allowsSchemaChanges()); $this->withoutVite(); [$this->editor, $this->reviewer] = User::factory()->count(2)->create()->all(); config(['portal.managers' => [$this->editor->email, $this->reviewer->email], 'portal.profile_access_enabled' => false, 'editorial_workflow.enabled' => true, 'editorial_workflow.portfolio' => true]); $this->fixture('projects'); } private function fixture(string $kind): void { $this->kind = $kind; $class = ['projects' => Project::class, 'clusters' => EconomicCluster::class, 'corridors' => DevelopmentCorridor::class][$kind]; $this->entity = $class::create(['code' => 'STRUCT-QA-'.bin2hex(random_bytes(5)), 'name' => 'Designação pública original', 'status' => 'active', ...($kind === 'clusters' ? ['sector' => 'Energia'] : ['description' => 'Descrição original']), ...($kind === 'projects' ? ['budget_amount' => '1500.25', 'budget_currency' => 'MZN'] : [])]); $foreign = ['projects' => 'project_id', 'clusters' => 'cluster_id', 'corridors' => 'corridor_id'][$kind]; $this->dossier = InvestmentDossier::create([$foreign => $this->entity->getKey(), 'title' => 'Ficha sintética de dados-base', 'source' => 'Fonte sintética QA', 'content' => ['description' => 'Conteúdo público original'], 'version_no' => 1, 'publication_status' => 'published', 'approval_basis' => 'document_owner', 'approval_evidence' => 'Publicação sintética de demonstração', 'published_at' => now()]); } private function fields(int $expected = 1): array { return ['editorial_version' => $expected, 'code' => $this->entity->getAttribute('code'), 'name' => 'Nova designação privada', 'status' => 'active', ...($this->kind === 'clusters' ? ['sector' => 'Indústria'] : ['description' => 'Descrição privada revista']), ...($this->kind === 'projects' ? ['budget_amount' => '999999999999999999.99', 'budget_currency' => 'USD'] : [])]; } private function save(int $expected = 1): \stdClass { $this->actingAsWithMfa($this->editor); $this->put('/management/'.$this->kind.'/'.$this->entity->getKey(), $this->fields($expected))->assertRedirect()->assertSessionHasNoErrors(); return app(PortalEditorialWorkflow::class)->latestPortfolio($this->dossier->id); } private function action(\stdClass $edition, string $action, int $status = 200): \stdClass { $actor = $action === 'submit' ? $this->editor : $this->reviewer; $this->actingAsWithMfa($actor); $this->postJson('/management/editorial/portfolio/'.$this->dossier->id.'/editions/'.$edition->id, ['version' => $edition->version, 'action' => $action, 'reason' => 'Decisão fundamentada de dados-base em QA'])->assertStatus($status); return DB::table('portal_editorial_editions')->where('id', $edition->id)->first(); } private function approve(\stdClass $edition): \stdClass { foreach (['submit', 'start_review', 'approve'] as $action) { $edition = $this->action($edition, $action); } return $edition; } public function test_project_cluster_and_corridor_base_data_and_fiche_are_published_atomically_without_precision_loss(): void { foreach (['projects', 'clusters', 'corridors'] as $kind) { if ($kind !== 'projects') { $this->fixture($kind); } $before = $this->entity->fresh()->getAttributes(); $this->actingAsWithMfa($this->editor)->put('/management/portfolio/'.$this->dossier->id, ['version_no' => 1, 'title' => 'Título privado deliberado', 'source' => 'Fonte QA', 'content' => ['description' => 'Texto deliberado em paralelo']])->assertRedirect(); $edition = $this->save(2); $this->assertSame($before, $this->entity->fresh()->getAttributes()); $this->get('/management/'.$kind.'/'.$this->entity->getKey().'/edit')->assertInertia(fn (Assert $p) => $p->where('record.name', 'Nova designação privada')->where('record.editorial_version', 3)->where('editorialDossier', $this->dossier->id)); $edition = $this->approve($edition); $this->assertSame($before, $this->entity->fresh()->getAttributes()); $this->get('/carteira/'.$this->dossier->id)->assertInertia(fn (Assert $p) => $p->where('record.content.description', 'Conteúdo público original')); $this->action($edition, 'publish'); $this->assertSame('Nova designação privada', $this->entity->fresh()->getAttribute('name')); $this->assertSame('Texto deliberado em paralelo', $this->dossier->fresh()->content['description']); if ($kind === 'projects') { $this->assertSame('999999999999999999.99', $this->entity->fresh()->getAttribute('budget_amount')); $this->get('/carteira/'.$this->dossier->id)->assertInertia(fn (Assert $p) => $p->where('anchorProject.name', 'Nova designação privada')->where('anchorProject.budget_currency', 'USD')); } $this->assertGreaterThan(0, DB::table('audit_events')->where('source_system', 'portal-management')->where('entity_id', $this->entity->getKey())->count()); if ($kind === 'corridors') { $this->action(app(PortalEditorialWorkflow::class)->latestPortfolio($this->dossier->id), 'archive'); $this->entity->update(['name' => 'Actualização de contexto após arquivo']); $this->get('/management/corridors/'.$this->entity->getKey().'/edit')->assertInertia(fn (Assert $p) => $p->where('record.name', 'Actualização de contexto após arquivo')); } $this->actingAsWithMfa($this->editor)->put('/management/portfolio/'.$this->dossier->id, ['version_no' => $this->dossier->fresh()->version_no, 'title' => 'Outra edição', 'source' => 'Fonte QA', 'content' => ['description' => 'Outra alteração narrativa']])->assertRedirect(); $next = app(PortalEditorialWorkflow::class)->latestPortfolio($this->dossier->id); $this->assertArrayNotHasKey('_structure', app(PortalEditorialWorkflow::class)->payload($next)['resource']); } } public function test_territory_is_staged_recovered_and_versioned_without_changing_the_public_location(): void { [$province, $district] = $this->locations(); $url = '/management/territories/projects/'.$this->entity->getKey(); $this->actingAsWithMfa($this->editor); $this->postJson($url, ['version' => 0, 'path' => [$province, $district]])->assertOk()->assertJsonPath('version', 1)->assertJsonPath('pending_publication', true); $this->assertNull($this->entity->fresh()->getAttribute('primary_territory_id')); $this->getJson($url)->assertOk()->assertJsonPath('version', 1)->assertJsonPath('path.0.id', $district); $this->postJson($url, ['version' => 0, 'path' => [$province]])->assertConflict(); $this->put('/management/projects/'.$this->entity->getKey(), $this->fields(1))->assertConflict(); $edition = $this->approve(app(PortalEditorialWorkflow::class)->latestPortfolio($this->dossier->id)); $this->assertNull($this->entity->fresh()->getAttribute('primary_territory_id')); $this->action($edition, 'publish'); $this->assertSame($district, $this->entity->fresh()->getAttribute('primary_territory_id')); $this->assertSame(1, (int) $this->entity->fresh()->getAttribute('territory_version')); $this->assertSame(1, DB::table('audit_events')->where('source_system', 'investment-territory')->where('after_data->record_id', $this->entity->getKey())->count()); $this->actingAsWithMfa($this->editor)->postJson($url, ['version' => 1, 'path' => []])->assertOk()->assertJsonPath('pending_publication', true); $this->assertSame($district, $this->entity->fresh()->getAttribute('primary_territory_id')); $edition = $this->approve(app(PortalEditorialWorkflow::class)->latestPortfolio($this->dossier->id)); $this->action($edition, 'publish'); $this->assertNull($this->entity->fresh()->getAttribute('primary_territory_id')); } public function test_conflicting_code_after_approval_rolls_back_base_data_and_fiche_together(): void { $data = $this->fields(); $data['code'] = 'CONFLICT-QA-'.bin2hex(random_bytes(4)); $this->actingAsWithMfa($this->editor)->put('/management/projects/'.$this->entity->getKey(), $data)->assertRedirect(); $edition = $this->approve(app(PortalEditorialWorkflow::class)->latestPortfolio($this->dossier->id)); Project::create(['code' => $data['code'], 'name' => 'Outro registo sintético', 'status' => 'planned']); $before = $this->entity->fresh()->getAttributes(); $fiche = $this->dossier->fresh()->getAttributes(); $this->action($edition, 'publish', 409); $this->assertSame($before, $this->entity->fresh()->getAttributes()); $this->assertSame($fiche, $this->dossier->fresh()->getAttributes()); $this->assertSame('approved', app(PortalEditorialWorkflow::class)->latestPortfolio($this->dossier->id)->state); } public function test_changed_territory_evidence_requires_a_new_approval_and_cross_context_intents_are_rejected(): void { [$province, $district] = $this->locations(); $this->actingAsWithMfa($this->editor)->postJson('/management/territories/projects/'.$this->entity->getKey(), ['version' => 0, 'path' => [$province, $district]])->assertOk(); $edition = $this->approve(app(PortalEditorialWorkflow::class)->latestPortfolio($this->dossier->id)); DB::table('geo_locations')->where('id', $district)->update(['name' => 'Localização revista']); $this->action($edition, 'publish', 409); $other = Project::create(['code' => 'OTHER-QA-'.bin2hex(random_bytes(4)), 'name' => 'Outro contexto', 'status' => 'planned']); $this->actingAsWithMfa($this->editor); try { app(PortalEditorialWorkflow::class)->saveStructure($this->editor, $other, $this->dossier, 2, ['name' => 'Não aplicar']); $this->fail('Uma alteração não pode migrar para outro contexto.'); } catch (HttpException $exception) { $this->assertSame(422, $exception->getStatusCode()); } $this->assertNull($this->entity->fresh()->getAttribute('primary_territory_id')); } public function test_missing_mfa_and_disabling_cannot_bypass_private_structure_editions(): void { $this->actingAsWithMfa($this->editor); session()->forget('portal.mfa_at'); $this->put('/management/projects/'.$this->entity->getKey(), $this->fields())->assertForbidden(); $this->save(); config(['editorial_workflow.portfolio' => false]); $this->put('/management/projects/'.$this->entity->getKey(), $this->fields(2))->assertStatus(503); $this->postJson('/management/territories/projects/'.$this->entity->getKey(), ['version' => 0, 'path' => []])->assertStatus(503); $this->assertSame('Designação pública original', $this->entity->fresh()->getAttribute('name')); } public function test_archived_fiche_cannot_be_used_to_bypass_base_data_review(): void { $this->save(); $this->dossier->delete(); $this->put('/management/projects/'.$this->entity->getKey(), $this->fields(2))->assertConflict(); $this->assertSame('Designação pública original', $this->entity->fresh()->getAttribute('name')); } private function locations(): array { $parent = null; $ids = []; foreach (['country', 'province', 'district'] as $type) { $id = (string) Str::uuid(); DB::table('geo_locations')->insert(['id' => $id, 'code' => $id, 'name' => 'Local sintético '.$type, 'location_type' => $type, 'parent_id' => $parent, 'data_classification' => 'public']); $parent = $id; $ids[$type] = $id; } return [$ids['province'], $ids['district']]; } }