Files
Investidor-V2/tests/Feature/EditorialStructuralSubjectTest.php
T
2026-11-14 11:15:09 +02:00

208 lines
13 KiB
PHP

<?php
namespace Tests\Feature;
use App\Models\EconomicCluster;
use App\Models\Project;
use App\Models\User;
use App\Models\ValueChain;
use App\Models\ValueChainLink;
use App\Services\PortalEditorialSubjects;
use App\Services\PortalEditorialWorkflow;
use App\Support\IsolatedQaDatabase;
use Illuminate\Foundation\Testing\DatabaseTransactions;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Str;
use Symfony\Component\HttpKernel\Exception\HttpException;
use Tests\Support\AuthenticatesQaActors;
use Tests\TestCase;
final class EditorialStructuralSubjectTest extends TestCase
{
use AuthenticatesQaActors,DatabaseTransactions;
private User $author;
private User $reviewer;
protected function setUp(): void
{
parent::setUp();
$this->assertTrue(IsolatedQaDatabase::allowsSchemaChanges());
$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.structural_subjects' => true]);
$this->actingAsWithMfa($this->author);
}
private function move(\stdClass $edition, string $action, ?User $actor = null): \stdClass
{
$actor ??= $action === 'submit' ? $this->author : $this->reviewer;
$this->actingAsWithMfa($actor);
return app(PortalEditorialWorkflow::class)->transition($actor, $edition->subject_key, $edition->id, ['action' => $action, 'version' => $edition->version, 'reason' => 'Synthetic subject review decision'], false, false, $edition->subject_kind);
}
private function approve(\stdClass $edition): \stdClass
{
foreach (['submit', 'start_review', 'approve'] as $action) {
$edition = $this->move($edition, $action);
}
return $edition;
}
public function test_a_structure_without_a_fiche_keeps_its_public_fields_until_explicit_publication(): void
{
$record = Project::create(['code' => 'SUB-'.Str::random(10), 'name' => 'Original public name', 'status' => 'active', 'data_classification' => 'public']);
$before = $record->fresh()->getAttributes();
$dossiersBefore = DB::table('investment_dossiers')->count();
$edition = app(PortalEditorialWorkflow::class)->saveSubject($this->author, 'projects', $record->id, 1, ['name' => 'Proposed private name', 'budget_amount' => '999999999999999999.99', 'budget_currency' => 'MZN']);
$this->assertEquals($before, $record->fresh()->getAttributes());
$this->assertSame(2, $edition->revision_no);
$edition = $this->approve($edition);
$this->assertEquals($before, $record->fresh()->getAttributes());
$edition = $this->move($edition, 'publish');
$this->assertSame('Proposed private name', $record->fresh()->name);
$this->assertSame('999999999999999999.99', $record->fresh()->getRawOriginal('budget_amount'));
$this->assertDatabaseCount('investment_dossiers', $dossiersBefore);
$this->assertSame('published', $edition->state);
}
public function test_relation_edits_do_not_invalidate_or_replace_the_current_public_association_before_publication(): void
{
$project = Project::create(['code' => 'REL-'.Str::random(10), 'name' => 'Synthetic project', 'status' => 'active']);
$cluster = EconomicCluster::create(['code' => 'REL-'.Str::random(10), 'name' => 'Synthetic cluster', 'sector' => 'Energy', 'status' => 'active']);
$id = (string) Str::uuid();
DB::table('cluster_projects')->insert(['id' => $id, 'cluster_id' => $cluster->id, 'project_id' => $project->id, 'role' => 'anchor', 'association_type' => 'potential', 'source_reference' => 'Synthetic identified source', 'evidence_reference' => 'Synthetic page 2', 'valid_from' => '2020-01-01', 'validation_status' => 'validated', 'editor_id' => $this->author->id, 'validator_id' => $this->reviewer->id, 'version_no' => 1, 'data_classification' => 'public']);
$before = DB::table('cluster_projects')->find($id);
$edition = app(PortalEditorialWorkflow::class)->saveSubject($this->author, 'cluster-project', $id, 1, ['role' => 'supporting']);
$edition = $this->move($edition, 'submit');
$edition = $this->move($edition, 'start_review');
try {
$this->move($edition, 'approve', $this->author);
$this->fail('Contributor approval must be rejected');
} catch (HttpException $e) {
$this->assertSame(403, $e->getStatusCode());
}
$edition = $this->move($edition, 'approve');
$this->assertEquals($before, DB::table('cluster_projects')->find($id));
$this->move($edition, 'publish');
$this->assertSame('supporting', DB::table('cluster_projects')->find($id)->role);
$this->assertSame('validated', DB::table('cluster_projects')->find($id)->validation_status);
}
public function test_new_value_chain_is_private_until_published_and_archiving_keeps_its_history(): void
{
$cluster = EconomicCluster::create(['code' => 'CHAIN-'.Str::random(10), 'name' => 'Synthetic cluster', 'sector' => 'Energy', 'status' => 'active']);
$chain = ValueChain::create(['cluster_id' => $cluster->id, 'code' => 'QA-'.Str::random(8), 'name' => 'Private new chain', 'stage' => 'production', 'deleted_at' => now()]);
$edition = app(PortalEditorialWorkflow::class)->saveSubject($this->author, 'value-chains', $chain->id, 0, [], true);
$this->assertNull(ValueChain::find($chain->id));
$edition = $this->approve($edition);
$this->assertNull(ValueChain::find($chain->id));
$edition = $this->move($edition, 'publish');
$this->assertNotNull(ValueChain::find($chain->id));
$edition = $this->move($edition, 'archive');
$this->assertNull(ValueChain::find($chain->id));
$this->assertNotNull(app(PortalEditorialWorkflow::class)->latestSubject('value-chains', $chain->id));
}
public function test_existing_editor_routes_and_private_subject_endpoints_preserve_context_and_permissions(): void
{
$record = Project::create(['code' => 'HTTP-'.Str::random(10), 'name' => 'Original HTTP name', 'status' => 'active']);
$this->put('/management/projects/'.$record->id, ['editorial_version' => 1, 'code' => $record->code, 'name' => 'Private HTTP name', 'status' => 'active'])->assertRedirect()->assertSessionHasNoErrors();
$this->assertSame('Original HTTP name', $record->fresh()->name);
$base = '/management/editorial/subjects/projects/'.$record->id;
$this->getJson($base)->assertOk()->assertHeader('Cache-Control', 'no-store, private')->assertJsonPath('edition.payload.resource._subject.fields.name', 'Private HTTP name');
$this->getJson('/management/editorial/subjects')->assertOk();
$this->getJson($base.'/choices?field=primary_territory_id')->assertOk();
$this->getJson($base.'/choices?field=password')->assertNotFound();
$this->postJson($base.'/drafts', ['expected_revision' => 2, 'fields' => ['data_classification' => 'public']])->assertUnprocessable();
$this->postJson($base.'/drafts', ['expected_revision' => 2, 'fields' => ['name' => 'Deliberate revised name']])->assertCreated();
$this->getJson($base)->assertJsonPath('edition.payload.resource._subject.fields.name', 'Deliberate revised name');
$outsider = User::factory()->create();
$this->actingAsWithMfa($outsider)->getJson($base)->assertForbidden();
$this->postJson($base.'/drafts', ['expected_revision' => 3, 'fields' => ['name' => 'Forbidden']])->assertForbidden();
$this->assertSame('Original HTTP name', $record->fresh()->name);
}
public function test_withdrawal_of_an_existing_chain_link_is_a_separate_reviewed_publication(): void
{
$cluster = EconomicCluster::create(['code' => 'WITHDRAW-'.Str::random(10), 'name' => 'Synthetic cluster', 'sector' => 'Energy', 'status' => 'active']);
$a = ValueChain::create(['cluster_id' => $cluster->id, 'code' => 'A-'.Str::random(5), 'name' => 'Origin', 'stage' => 'production', 'data_classification' => 'public']);
$b = ValueChain::create(['cluster_id' => $cluster->id, 'code' => 'B-'.Str::random(5), 'name' => 'Destination', 'stage' => 'processing', 'data_classification' => 'public']);
$link = ValueChainLink::create(['from_value_chain_id' => $a->id, 'to_value_chain_id' => $b->id, 'link_type' => 'supplies', 'data_classification' => 'public']);
$this->delete('/management/clusters/'.$cluster->id.'/chain-links/'.$link->id)->assertRedirect('/management/editorial/subjects/value-chain-links/'.$link->id);
$this->assertNotNull(ValueChainLink::find($link->id));
$edition = app(PortalEditorialWorkflow::class)->latestSubject('value-chain-links', $link->id);
$this->assertTrue(app(PortalEditorialWorkflow::class)->payload($edition)['resource']['_subject']['withdraw']);
$edition = $this->approve($edition);
$this->assertNotNull(ValueChainLink::find($link->id));
$this->move($edition, 'publish');
$this->assertNull(ValueChainLink::find($link->id));
$this->getJson('/management/editorial/subjects/value-chain-links/'.$link->id)->assertOk();
}
public function test_new_chain_form_creates_a_private_proposal_in_the_existing_model(): void
{
$cluster = EconomicCluster::create(['code' => 'FORM-'.Str::random(10), 'name' => 'Synthetic cluster', 'sector' => 'Energy', 'status' => 'active']);
$code = 'NEW-'.Str::random(10);
$this->post('/management/clusters/'.$cluster->id.'/chains', ['code' => $code, 'name' => 'Private new stage', 'stage' => 'production'])->assertRedirect()->assertSessionHasNoErrors();
$chain = ValueChain::withTrashed()->where('code', $code)->firstOrFail();
$this->assertNull(ValueChain::find($chain->id));
$edition = app(PortalEditorialWorkflow::class)->latestSubject('value-chains', $chain->id);
$this->assertSame('draft', $edition->state);
$this->getJson('/management/editorial/subjects/value-chains/'.$chain->id)->assertOk()->assertJsonPath('edition.payload.resource._subject.new_record', true);
}
public function test_duplicate_relation_publication_is_refused_and_keeps_both_original_links(): void
{
$cluster = EconomicCluster::create(['code' => 'DUP-'.Str::random(10), 'name' => 'Synthetic cluster', 'sector' => 'Energy', 'status' => 'active']);
$chains = collect(['A', 'B', 'C'])->map(fn ($code) => ValueChain::create(['cluster_id' => $cluster->id, 'code' => $code, 'name' => $code, 'stage' => 'production']));
$existing = ValueChainLink::create(['from_value_chain_id' => $chains[0]->id, 'to_value_chain_id' => $chains[1]->id, 'link_type' => 'supplies']);
$edited = ValueChainLink::create(['from_value_chain_id' => $chains[0]->id, 'to_value_chain_id' => $chains[2]->id, 'link_type' => 'supplies']);
$edition = app(PortalEditorialWorkflow::class)->saveSubject($this->author, 'value-chain-links', $edited->id, 1, ['to_value_chain_id' => $chains[1]->id]);
$edition = $this->approve($edition);
try {
$this->move($edition, 'publish');
$this->fail('Duplicate must be refused before writing');
} catch (HttpException $e) {
$this->assertSame(409, $e->getStatusCode());
}
$this->assertSame($chains[2]->id, $edited->fresh()->to_value_chain_id);
$this->assertNotNull($existing->fresh());
}
public function test_partial_edits_validate_against_the_preserved_dates_and_currency(): void
{
$record = Project::create(['code' => 'DATES-'.Str::random(10), 'name' => 'Original', 'status' => 'active', 'start_date' => '2026-05-01']);
$base = '/management/editorial/subjects/projects/'.$record->id.'/drafts';
$this->postJson($base, ['expected_revision' => 1, 'fields' => ['end_date' => '2026-04-01']])->assertUnprocessable();
$this->postJson($base, ['expected_revision' => 1, 'fields' => ['budget_amount' => '2000.25']])->assertUnprocessable();
$this->assertSame(0, DB::table('portal_editorial_editions')->where('subject_key', $record->id)->count());
$this->assertSame('Original', $record->fresh()->name);
}
public function test_context_changes_and_disabled_workflow_do_not_allow_legacy_bypass(): void
{
$record = Project::create(['code' => 'STALE-'.Str::random(10), 'name' => 'Original', 'status' => 'active']);
$edition = app(PortalEditorialWorkflow::class)->saveSubject($this->author, 'projects', $record->id, 1, ['name' => 'Private']);
$edition = $this->approve($edition);
$record->forceFill(['description' => 'Changed contextual evidence'])->save();
try {
$this->move($edition, 'publish');
$this->fail('Context changed');
} catch (HttpException $e) {
$this->assertSame(409, $e->getStatusCode());
}
$this->assertSame('Original', $record->fresh()->name);
config(['editorial_workflow.structural_subjects' => false]);
try {
app(PortalEditorialSubjects::class)->writes('projects', $record->id);
$this->fail('Legacy bypass');
} catch (HttpException $e) {
$this->assertSame(503, $e->getStatusCode());
}
}
}