99 lines
5.2 KiB
PHP
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();
|
|
}
|