109 lines
7.6 KiB
PHP
109 lines
7.6 KiB
PHP
<?php
|
|
|
|
namespace Tests\Feature;
|
|
|
|
use App\Models\EconomicCluster;
|
|
use App\Models\InvestmentDossier;
|
|
use App\Models\Project;
|
|
use App\Models\User;
|
|
use App\Services\InvestmentTerritorialTraceability;
|
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
|
use Illuminate\Support\Facades\DB;
|
|
use Illuminate\Support\Str;
|
|
use Tests\TestCase;
|
|
|
|
class InvestmentTerritorialTraceabilityTest extends TestCase
|
|
{
|
|
use RefreshDatabase;
|
|
|
|
private string $directory;
|
|
|
|
protected function setUp(): void
|
|
{
|
|
parent::setUp();
|
|
$this->directory = storage_path('framework/testing/territorial-'.Str::uuid());
|
|
mkdir($this->directory, 0700, true);
|
|
config(['investment.traceability_directory' => $this->directory]);
|
|
}
|
|
|
|
protected function tearDown(): void
|
|
{
|
|
foreach (glob($this->directory.'/*') ?: [] as $file) {
|
|
unlink($file);
|
|
}
|
|
rmdir($this->directory);
|
|
parent::tearDown();
|
|
}
|
|
|
|
private function fixture(): array
|
|
{
|
|
$project = Project::factory()->create();
|
|
$cluster = EconomicCluster::create(['code' => 'QA-TRACE', 'name' => 'Synthetic QA cluster', 'sector' => 'QA', 'status' => 'planned']);
|
|
$dossier = InvestmentDossier::create(['cluster_id' => $cluster->id, 'title' => 'QA synthetic source', 'source' => 'Synthetic fixture only', 'content' => [], 'publication_status' => 'published', 'published_at' => now()->subMinute(), 'approval_basis' => 'document_owner', 'approval_evidence' => 'QA synthetic disclosure']);
|
|
$id = (string) Str::uuid();
|
|
DB::table('cluster_projects')->insert(['id' => $id, 'cluster_id' => $cluster->id, 'project_id' => $project->id, 'role' => 'anchor', 'source_system' => 'QA']);
|
|
$row = DB::table('cluster_projects')->where('id', $id)->first();
|
|
$plan = ['schema' => 1, 'target' => ['database' => config('database.connections.pgsql.database'), 'port' => 55441], 'code_hash' => app(InvestmentTerritorialTraceability::class)->fingerprint(),
|
|
'rows' => [['kind' => 'economic_clusters', 'record_id' => $cluster->id, 'association' => $project->id, 'role' => 'context', 'public' => true, 'dossier_id' => $dossier->id, 'source_reference' => 'QA synthetic source', 'source_quote' => 'QA anchor', 'supports' => 'Existing membership only', 'traceability' => 'quote recovered', 'official_source_accepted' => false, 'existing_validation' => $row->validation_status]],
|
|
'guards' => [['table' => 'cluster_projects', 'id' => $id, 'expected' => ['version_no' => $row->version_no, 'validation_status' => $row->validation_status, 'source_reference' => null]]],
|
|
'updates' => [['table' => 'cluster_projects', 'id' => $id, 'values' => ['source_reference' => 'QA synthetic source', 'evidence_reference' => $dossier->id]]]];
|
|
$bytes = json_encode($plan, JSON_THROW_ON_ERROR);
|
|
file_put_contents($this->directory.'/territory-activation-plan.json', $bytes);
|
|
file_put_contents($this->directory.'/qa-backup-fixture', 'QA fixture, not active backup evidence');
|
|
$hash = hash('sha256', $bytes);
|
|
$proof = ['restore_verified' => true, 'reversal_verified' => true, 'target' => $plan['target'], 'plan_hash' => $hash, 'code_hash' => $plan['code_hash'], 'verified_at' => gmdate('c'), 'dump_path' => $this->directory.'/qa-backup-fixture', 'sha256' => hash_file('sha256', $this->directory.'/qa-backup-fixture')];
|
|
file_put_contents($this->directory.'/territory-backup-proof.json', json_encode($proof, JSON_THROW_ON_ERROR));
|
|
$manager = User::factory()->create(['email' => 'territory-manager@example.invalid']);
|
|
config(['portal.managers' => [$manager->email]]);
|
|
|
|
return compact('hash', 'manager', 'id', 'cluster', 'dossier', 'row');
|
|
}
|
|
|
|
public function test_authenticated_application_is_idempotent_and_reversible_without_revalidating_sources(): void
|
|
{
|
|
$f = $this->fixture();
|
|
$payload = ['operation' => 'traceability', 'hash' => $f['hash'], 'reverse' => false];
|
|
$this->actingAs($f['manager'])->postJson('/management/relationship-repair', $payload)->assertOk()->assertJsonPath('changed', 1);
|
|
$this->postJson('/management/relationship-repair', $payload)->assertOk()->assertJsonPath('replayed', true)->assertJsonPath('changed', 0);
|
|
$service = app(InvestmentTerritorialTraceability::class);
|
|
$this->assertCount(1, $service->sources('economic_clusters', $f['cluster']->id));
|
|
$this->assertDatabaseHas('cluster_projects', ['id' => $f['id'], 'validation_status' => $f['row']->validation_status, 'source_reference' => 'QA synthetic source']);
|
|
$f['dossier']->update(['publication_status' => 'draft']);
|
|
$this->assertSame([], $service->sources('economic_clusters', $f['cluster']->id));
|
|
$this->postJson('/management/relationship-repair', [...$payload, 'reverse' => true])->assertUnprocessable()->assertJsonValidationErrors(['reason', 'confirm_reverse']);
|
|
$this->postJson('/management/relationship-repair', [...$payload, 'reverse' => true, 'reason' => 'QA reversal preservation test', 'confirm_reverse' => true])->assertOk()->assertJsonPath('changed', 1);
|
|
$this->assertSame('QA reversal preservation test', app(InvestmentTerritorialTraceability::class)->preview()['history'][0]['reason']);
|
|
$this->assertDatabaseHas('cluster_projects', ['id' => $f['id'], 'version_no' => $f['row']->version_no + 2, 'source_reference' => null, 'evidence_reference' => null, 'validation_status' => $f['row']->validation_status]);
|
|
$this->assertSame([], $service->sources('economic_clusters', $f['cluster']->id));
|
|
$this->assertSame(2, DB::table('audit_events')->where('source_system', 'territory-traceability-v2')->count());
|
|
$this->postJson('/management/relationship-repair', $payload)->assertConflict();
|
|
}
|
|
|
|
public function test_permissions_and_conflicting_snapshot_prevent_writes_and_show_field_error(): void
|
|
{
|
|
$f = $this->fixture();
|
|
$payload = ['operation' => 'traceability', 'hash' => $f['hash'], 'reverse' => false];
|
|
$this->actingAs(User::factory()->create())->postJson('/management/relationship-repair', $payload)->assertForbidden();
|
|
DB::table('cluster_projects')->where('id', $f['id'])->increment('version_no');
|
|
$this->actingAs($f['manager'])->postJson('/management/relationship-repair', $payload)->assertConflict();
|
|
$this->from('/management/relationship-repair')->post('/management/relationship-repair', $payload)->assertRedirect('/management/relationship-repair')->assertSessionHasErrors('operation');
|
|
$this->assertDatabaseHas('cluster_projects', ['id' => $f['id'], 'source_reference' => null]);
|
|
$this->assertSame(0, DB::table('audit_events')->where('source_system', 'territory-traceability-v2')->count());
|
|
}
|
|
|
|
public function test_expired_backup_and_modified_code_proof_require_updated_plan(): void
|
|
{
|
|
$f = $this->fixture();
|
|
$proofFile = $this->directory.'/territory-backup-proof.json';
|
|
$proof = json_decode(file_get_contents($proofFile), true);
|
|
$proof['verified_at'] = gmdate('c', time() - 86401);
|
|
file_put_contents($proofFile, json_encode($proof));
|
|
$this->actingAs($f['manager'])->postJson('/management/relationship-repair', ['operation' => 'traceability', 'hash' => $f['hash'], 'reverse' => false])->assertUnprocessable();
|
|
$proof['verified_at'] = gmdate('c');
|
|
$proof['code_hash'] = 'wrong';
|
|
file_put_contents($proofFile, json_encode($proof));
|
|
$this->assertFalse(app(InvestmentTerritorialTraceability::class)->preview()['backup_ready']);
|
|
$this->assertDatabaseHas('cluster_projects', ['id' => $f['id'], 'source_reference' => null]);
|
|
}
|
|
}
|