214 lines
13 KiB
PHP
214 lines
13 KiB
PHP
<?php
|
|
|
|
namespace Tests\Feature;
|
|
|
|
use App\Models\DevelopmentCorridor;
|
|
use App\Models\EconomicCluster;
|
|
use App\Models\InvestmentDossier;
|
|
use App\Models\Project;
|
|
use App\Models\User;
|
|
use App\Services\PortalEditorialWorkflow;
|
|
use App\Support\IsolatedQaDatabase;
|
|
use Illuminate\Database\Eloquent\Model;
|
|
use Illuminate\Foundation\Testing\DatabaseTransactions;
|
|
use Illuminate\Support\Facades\DB;
|
|
use Illuminate\Support\Str;
|
|
use Inertia\Testing\AssertableInertia as Assert;
|
|
use Symfony\Component\HttpKernel\Exception\HttpException;
|
|
use Tests\Support\AuthenticatesQaActors;
|
|
use Tests\TestCase;
|
|
|
|
final class PortfolioStructureEditorialTest extends TestCase
|
|
{
|
|
use AuthenticatesQaActors, DatabaseTransactions;
|
|
|
|
private User $editor;
|
|
|
|
private User $reviewer;
|
|
|
|
private Model $entity;
|
|
|
|
private InvestmentDossier $dossier;
|
|
|
|
private string $kind;
|
|
|
|
protected function setUp(): void
|
|
{
|
|
parent::setUp();
|
|
$this->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']];
|
|
}
|
|
}
|