Files
Investidor-V2/tools/test-project-dependencies.php
nala 3dc7c5c649
tests / ci (push) Canceled after 0s
commit 30/9
2026-09-30 09:05:43 +02:00

99 lines
5.2 KiB
PHP

<?php
use Illuminate\Contracts\Console\Kernel;
use Illuminate\Database\QueryException;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Str;
require __DIR__.'/../vendor/autoload.php';
$app = require __DIR__.'/../bootstrap/app.php';
$app->make(Kernel::class)->bootstrap();
$config = DB::connection()->getConfig();
if (! app()->environment('testing') || $config['database'] !== 'portal_services_test' || (string) $config['port'] !== '55441' || ! in_array($config['host'], ['127.0.0.1', 'localhost'], true)) {
throw new RuntimeException('Requires isolated portal_services_test on localhost:55441.');
}
$migration = require database_path('migrations/2026_09_30_110000_project_dependency_integrity.php');
$assertions = 0;
$check = function (bool $condition) use (&$assertions): void {
if (! $condition) {
throw new RuntimeException('Dependency integrity assertion failed: '.($assertions + 1));
}
$assertions++;
};
$ids = array_map(fn () => (string) Str::uuid(), range(1, 3));
$link = fn (int $a, int $b, string $kind = 'dependency'): array => ['anchor_project_id' => $ids[$a], 'complementary_project_id' => $ids[$b], 'relation_type' => $kind];
$reject = function (callable $write) use ($check): void {
try {
$write();
} catch (QueryException $e) {
$check($e->getCode() === '23514');
return;
}
throw new RuntimeException('Cycle was accepted');
};
try {
foreach ($ids as $id) {
DB::table('projects')->insert(['id' => $id, 'code' => 'test-dependency-'.$id, 'name' => 'Synthetic dependency test', 'status' => 'draft']);
}
DB::table('complementary_project_links')->insert($link(0, 1));
$reject(fn () => DB::table('complementary_project_links')->insert($link(1, 0)));
DB::table('complementary_project_links')->insert($link(1, 2));
$reject(fn () => DB::table('complementary_project_links')->insert($link(2, 0)));
$reject(fn () => DB::table('complementary_project_links')->insert($link(0, 0)));
// Non-dependency relations may form a network; do not impose DAG semantics on them.
DB::table('complementary_project_links')->insert($link(2, 0, 'supplier'));
$reject(fn () => DB::table('complementary_project_links')->where('anchor_project_id', $ids[2])->update(['relation_type' => 'dependency']));
DB::table('complementary_project_links')->where('anchor_project_id', $ids[1])->update(['deleted_at' => now()]);
DB::table('complementary_project_links')->insert($link(2, 0));
$reject(fn () => DB::table('complementary_project_links')->where('anchor_project_id', $ids[1])->update(['deleted_at' => null]));
DB::table('complementary_project_links')->whereIn('anchor_project_id', $ids)->delete();
// Two real sessions: B→A waits for A→B, then must reject the resulting cycle.
$parts = [];
foreach (['host' => 'host', 'port' => 'port', 'dbname' => 'database', 'user' => 'username', 'password' => 'password'] as $key => $name) {
$parts[] = $key."='".str_replace(['\\', "'"], ['\\\\', "\\'"], (string) ($config[$name] ?? ''))."'";
}
$second = pg_connect(implode(' ', $parts), PGSQL_CONNECT_FORCE_NEW);
if ($second === false) {
throw new RuntimeException('Cannot connect second isolated session');
}
pg_query($second, "SET statement_timeout='10s'");
DB::beginTransaction();
DB::table('complementary_project_links')->insert($link(0, 1));
pg_send_query_params($second, "INSERT INTO public.complementary_project_links(anchor_project_id,complementary_project_id,relation_type) VALUES($1,$2,'dependency')", [$ids[1], $ids[0]]);
// Confirm the second statement is waiting on our advisory lock, not merely unscheduled.
$waiting = false;
for ($attempt = 0; $attempt < 100; $attempt++) {
$state = DB::selectOne('SELECT wait_event FROM pg_stat_activity WHERE pid=?', [pg_get_pid($second)]);
if (($state->wait_event ?? null) === 'advisory') {
$waiting = true;
break;
}
usleep(20000);
DB::statement('SELECT pg_stat_clear_snapshot()');
}
$check($waiting);
DB::commit();
$result = pg_get_result($second);
$check($result !== false && pg_result_error_field($result, PGSQL_DIAG_SQLSTATE) === '23514');
pg_close($second);
$check(DB::table('complementary_project_links')->whereIn('anchor_project_id', $ids)->count() === 1);
DB::table('complementary_project_links')->whereIn('anchor_project_id', $ids)->delete();
// Non-destructive DOWN/UP: schema reversibility without dropping any master or row.
DB::transaction(function () use ($migration, $check): void {
$migration->down();
$check(DB::selectOne("SELECT to_regprocedure('public.check_project_dependency_graph()') AS id")->id === null);
$migration->up();
$check(DB::selectOne("SELECT to_regprocedure('public.check_project_dependency_graph()') AS id")->id !== null);
});
echo json_encode(['passed' => true, 'assertions' => $assertions, 'two_session_cycle_rejected' => true, 'rollback_reapplication' => true], JSON_PRETTY_PRINT).PHP_EOL;
} finally {
while (DB::transactionLevel() > 0) {
DB::rollBack();
}
DB::table('complementary_project_links')->whereIn('anchor_project_id', $ids)->delete();
DB::table('projects')->whereIn('id', $ids)->delete();
}