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()); } } }