commit15
This commit is contained in:
@@ -0,0 +1,74 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Benchmarks;
|
||||
|
||||
use App\Modules\Identity\Http\ModuleRecordsController;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
require_once __DIR__.'/ModuleAssociationCompareTest.php';
|
||||
|
||||
final class ModuleAssociationAccessParityTest extends ModuleAssociationLoadTest
|
||||
{
|
||||
public function test_synthetic_volume_and_query_plans(): void
|
||||
{
|
||||
$run = (string) getenv('SGF_ISOLATED_REGRESSION_RUN');
|
||||
$this->assertMatchesRegularExpression('/^[a-f0-9]{12}$/D', $run);
|
||||
$id = DB::selectOne("SELECT current_database() AS database,current_user AS role,inet_server_port() AS port,current_setting('cluster_name') AS cluster");
|
||||
$this->assertSame('sgf_tempo_test', $id->database); $this->assertSame('sgf_test_runner', $id->role);
|
||||
$this->assertSame('sgf_modular_regression_'.$run, $id->cluster); $this->assertSame(55439, (int) $id->port);
|
||||
require_once base_path('../baseline-review.php');
|
||||
config(['app.debug' => false]);
|
||||
$f = $this->fixture(1000); $actor = $f['identity']; $report = ['date' => '2026-11-02', 'variants' => []];
|
||||
foreach ([false, true] as $indexed) {
|
||||
DB::beginTransaction();
|
||||
try {
|
||||
if ($indexed) {
|
||||
DB::statement("CREATE INDEX sgf_qa_assoc_links ON identity_audit (tenant_id,company_id,(details->>'area'),(details->>'record_id'),(details->>'module_id'),id DESC) WHERE action IN ('module.record.associated','module.record.disassociated')");
|
||||
DB::statement("CREATE INDEX sgf_qa_assoc_revision ON identity_audit (tenant_id,company_id,(details->>'area'),(details->>'record_id'),id DESC) WHERE action IN ('module.record.associated','module.record.disassociated','module.record.reviewed')");
|
||||
}
|
||||
$checks = [];
|
||||
foreach (['reader', 'limited'] as $profile) {
|
||||
$this->selectActor($actor, $profile);
|
||||
foreach (['baseline', 'batch'] as $mode) { $this->useController($mode); $this->getJson('/api/module-records')->assertForbidden(); }
|
||||
$checks[$profile] = 403;
|
||||
}
|
||||
$token = $this->selectActor($actor, 'manager');
|
||||
foreach (['baseline', 'batch'] as $mode) {
|
||||
$this->useController($mode);
|
||||
$this->getJson('/api/module-records?unit_id='.$actor['hidden'])->assertForbidden();
|
||||
$this->getJson('/api/module-records?per_page=101')->assertUnprocessable();
|
||||
$this->withHeader('X-SGF-Context', 'obsolete')->getJson('/api/module-records')->assertConflict();
|
||||
$this->withHeader('X-SGF-Context', $token);
|
||||
}
|
||||
$checks += ['unselected_unit' => 403, 'invalid_page_size' => 422, 'old_generation' => 409];
|
||||
DB::table('permission_grants')->where('user_id', $actor['manager']->id)->where('company_id', $actor['company'])->where('permission', 'services.view')->delete();
|
||||
$hashes = [];
|
||||
foreach (['baseline', 'batch'] as $mode) {
|
||||
$this->useController($mode);
|
||||
$r = $this->getJson('/api/module-records?area=billing_drafts&per_page=25')->assertOk();
|
||||
foreach ($r->json('records.billing_drafts') as $row) { $this->assertTrue($row['dependencies_restricted']); $this->assertSame([], $row['dependencies']); }
|
||||
$hashes[] = hash('sha256', $r->getContent());
|
||||
}
|
||||
$this->assertSame($hashes[0], $hashes[1]);
|
||||
$report['variants'][] = ['experimental_indexes' => $indexed, 'refusals_equal' => $checks, 'revoked_dependency_response_identical' => true, 'response_sha256' => $hashes[0]];
|
||||
} finally { DB::rollBack(); }
|
||||
}
|
||||
$this->assertSame(0, (int) DB::selectOne("SELECT count(*) AS n FROM pg_indexes WHERE schemaname='public' AND indexname LIKE 'sgf_qa_assoc_%'")->n);
|
||||
$report['indexes_rolled_back'] = true;
|
||||
$output = getenv('SGF_ASSOCIATION_LOAD_OUTPUT');
|
||||
if (!$output || realpath(dirname($output)) !== realpath(base_path('..'))) throw new \RuntimeException('Unexpected report directory');
|
||||
file_put_contents($output, json_encode($report, JSON_PRETTY_PRINT | JSON_THROW_ON_ERROR).PHP_EOL);
|
||||
}
|
||||
|
||||
private function selectActor(array $f, string $profile): string
|
||||
{
|
||||
$user = $f[$profile]; $this->actingAs($user)->withSession(['auth_version' => $user->auth_version]);
|
||||
$token = $this->postJson('/api/context', ['tenant_id' => $f['tenant'], 'company_id' => $f['company'], 'unit_id' => $f['unit'], 'module_id' => null])->assertOk()->json('context.token');
|
||||
$this->withHeader('X-SGF-Context', $token); return $token;
|
||||
}
|
||||
private function useController(string $mode): void
|
||||
{
|
||||
app()->bind(ModuleRecordsController::class, fn () => $mode === 'baseline' ? new BaselineAssociationController() : new ModuleRecordsController());
|
||||
foreach (app('router')->getRoutes() as $route) if ($route->uri() === 'api/module-records') $route->flushController();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,160 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Benchmarks;
|
||||
|
||||
use App\Modules\Identity\Http\ModuleRecordsController;
|
||||
use App\Modules\Identity\Services\BaselineModuleAssociationReview;
|
||||
use Illuminate\Database\Events\QueryExecuted;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
require_once __DIR__.'/ModuleAssociationLoadTest.php';
|
||||
|
||||
final class BaselineAssociationController
|
||||
{
|
||||
public function index(Request $r) { return response()->json(app(BaselineModuleAssociationReview::class)->listing($r))->header('Cache-Control', 'no-store'); }
|
||||
}
|
||||
|
||||
final class ModuleAssociationCompareTest extends ModuleAssociationLoadTest
|
||||
{
|
||||
private ?array $metrics = null;
|
||||
private array $sql = [];
|
||||
private int $start = 0;
|
||||
|
||||
// Overrides the old test: only this paired experiment is executed.
|
||||
public function test_synthetic_volume_and_query_plans(): void
|
||||
{
|
||||
$this->assertSame('8.4.27', PHP_VERSION);
|
||||
$run = (string) getenv('SGF_ISOLATED_REGRESSION_RUN');
|
||||
$this->assertMatchesRegularExpression('/^[a-f0-9]{12}$/D', $run);
|
||||
$identity = DB::selectOne("SELECT current_database() AS database,current_user AS role,inet_server_port() AS port,current_setting('cluster_name') AS cluster");
|
||||
$this->assertSame('sgf_tempo_test', $identity->database); $this->assertSame('sgf_test_runner', $identity->role);
|
||||
$this->assertSame('sgf_modular_regression_'.$run, $identity->cluster); $this->assertSame(55439, (int) $identity->port);
|
||||
require_once base_path('../baseline-review.php');
|
||||
$this->withoutExceptionHandling();
|
||||
DB::listen(function (QueryExecuted $q) {
|
||||
if ($this->metrics === null) return;
|
||||
if (!preg_match('/^\s*(select|with)\b/i', $q->sql)) throw new \RuntimeException('READ_WORKLOAD_WROTE_SQL');
|
||||
$this->metrics['queries']++; $this->metrics['database_ms'] += $q->time;
|
||||
$key = hash('sha256', $q->sql);
|
||||
$entry = $this->sql[$key] ?? ['sql' => $q->sql, 'bindings' => $q->bindings, 'count' => 0, 'database_ms' => 0.0];
|
||||
$entry['count']++; $entry['database_ms'] += $q->time; $this->sql[$key] = $entry;
|
||||
if ((hrtime(true) - $this->start) / 1e6 > 15000 || $this->metrics['queries'] > 2500) throw new \RuntimeException('CASE_BUDGET_REACHED');
|
||||
});
|
||||
$report = ['date' => '2026-11-02', 'identity' => $identity, 'baseline_checkpoint' => '340f3bb', 'baseline_sha256' => getenv('SGF_ASSOCIATION_BASELINE_SHA'), 'optimized_sha256' => getenv('SGF_ASSOCIATION_OPTIMIZED_SHA'),
|
||||
'method' => 'Same immutable fixture per scale; baseline/batch requests alternate order across three repetitions; indexed batch uses the identical fixture after index creation in a savepoint. Warm kernel/buffers, no concurrent capacity claim.',
|
||||
'bounds' => ['request_ms' => 15000, 'statement_ms' => 3000, 'queries' => 2500], 'datasets' => []];
|
||||
$initial = $this->fingerprints();
|
||||
try {
|
||||
foreach ([1000, 5000, 10000] as $n) {
|
||||
DB::beginTransaction();
|
||||
try {
|
||||
$f = $this->fixture($n, true); $actor = $f['identity'];
|
||||
$this->actingAs($actor['manager'])->withSession(['auth_version' => $actor['manager']->auth_version]);
|
||||
$token = $this->postJson('/api/context', ['tenant_id' => $actor['tenant'], 'company_id' => $actor['company'], 'unit_id' => $actor['unit'], 'module_id' => null])->assertOk()->json('context.token');
|
||||
$this->withHeader('X-SGF-Context', $token);
|
||||
$dataset = ['records_per_area' => $n, 'events' => DB::table('identity_audit')->where('tenant_id', $actor['tenant'])->where('action', 'like', 'module.record.%')->count(), 'cases' => []];
|
||||
$cases = [];
|
||||
foreach (array_keys($f['ids']) as $area) foreach ([25, 100] as $size) $cases[] = [$area, 'all', $size];
|
||||
foreach (['pending', 'associated', 'removed'] as $state) $cases[] = ['clients', $state, 25];
|
||||
foreach ($cases as [$area, $state, $size]) {
|
||||
$expected = array_values(array_filter($f['ids'][$area], fn ($id) => match ($state) { 'pending' => $id % 3 === 0, 'associated' => $id % 3 === 1, 'removed' => $id % 3 === 2, default => true }));
|
||||
$case = ['area' => $area, 'state' => $state, 'page_size' => $size, 'variants' => ['baseline' => [], 'batch' => [], 'indexed_batch' => []]];
|
||||
foreach (['baseline', 'batch'] as $mode) { $this->mode($mode); $this->getJson('/api/module-records?area='.$area.'&per_page=1')->assertOk(); }
|
||||
for ($i = 0; $i < 3; $i++) foreach ($i % 2 ? ['batch', 'baseline'] : ['baseline', 'batch'] as $mode) {
|
||||
$prior = $case['variants'][$mode];
|
||||
if ($prior && end($prior)['status'] !== 'ok') continue;
|
||||
$this->mode($mode); $case['variants'][$mode][] = $this->sample($area, $state, $size, $expected);
|
||||
}
|
||||
$dataset['cases'][] = $case;
|
||||
fwrite(STDOUT, "\nPAIR $n $area/$state/$size baseline=".$this->brief($case['variants']['baseline']).' batch='.$this->brief($case['variants']['batch'])."\n");
|
||||
}
|
||||
DB::beginTransaction();
|
||||
try {
|
||||
$dataset['experimental_indexes'] = [
|
||||
"CREATE INDEX sgf_qa_assoc_links ON identity_audit (tenant_id,company_id,(details->>'area'),(details->>'record_id'),(details->>'module_id'),id DESC) WHERE action IN ('module.record.associated','module.record.disassociated')",
|
||||
"CREATE INDEX sgf_qa_assoc_revision ON identity_audit (tenant_id,company_id,(details->>'area'),(details->>'record_id'),id DESC) WHERE action IN ('module.record.associated','module.record.disassociated','module.record.reviewed')",
|
||||
];
|
||||
DB::statement("SET LOCAL statement_timeout = '30s'");
|
||||
foreach ($dataset['experimental_indexes'] as $ddl) DB::statement($ddl);
|
||||
DB::statement('ANALYZE identity_audit');
|
||||
$this->mode('indexed_batch');
|
||||
foreach ($dataset['cases'] as &$case) {
|
||||
$area = $case['area']; $state = $case['state']; $size = $case['page_size'];
|
||||
$expected = array_values(array_filter($f['ids'][$area], fn ($id) => match ($state) { 'pending' => $id % 3 === 0, 'associated' => $id % 3 === 1, 'removed' => $id % 3 === 2, default => true }));
|
||||
$this->getJson('/api/module-records?area='.$area.'&per_page=1')->assertOk();
|
||||
for ($i = 0; $i < 3; $i++) { $sample = $this->sample($area, $state, $size, $expected); $case['variants']['indexed_batch'][] = $sample; if ($sample['status'] !== 'ok') break; }
|
||||
$hashes = [];
|
||||
foreach ($case['variants'] as $samples) foreach ($samples as $sample) if ($sample['status'] === 'ok') $hashes[] = $sample['response_sha256'];
|
||||
$this->assertCount(1, array_unique($hashes), 'Successful variants must return byte-identical JSON');
|
||||
$case['successful_responses_identical'] = true;
|
||||
foreach ($case['variants'] as $mode => $samples) $case['summary'][$mode] = $this->summarize($samples);
|
||||
fwrite(STDOUT, "\nINDEX $n $area/$state/$size ".$this->brief($case['variants']['indexed_batch'])."\n");
|
||||
}
|
||||
unset($case);
|
||||
} finally { $this->metrics = null; DB::rollBack(); }
|
||||
$this->assertSame(0, (int) DB::selectOne("SELECT count(*) AS n FROM pg_indexes WHERE schemaname='public' AND indexname LIKE 'sgf_qa_assoc_%'")->n);
|
||||
$dataset['experimental_indexes_rolled_back'] = true;
|
||||
$report['datasets'][] = $dataset;
|
||||
} finally { $this->metrics = null; $this->mode('batch'); DB::rollBack(); }
|
||||
$this->assertSame($initial, $this->fingerprints(), 'Committed records must survive fixture rollback unchanged');
|
||||
}
|
||||
$report['committed_data_and_index_catalog_preserved'] = true; $report['completed'] = true;
|
||||
} finally {
|
||||
$output = getenv('SGF_ASSOCIATION_LOAD_OUTPUT');
|
||||
if (!$output || realpath(dirname($output)) !== realpath(base_path('..'))) throw new \RuntimeException('Unexpected report directory');
|
||||
file_put_contents($output, json_encode($report, JSON_PRETTY_PRINT | JSON_THROW_ON_ERROR).PHP_EOL);
|
||||
}
|
||||
}
|
||||
|
||||
private function mode(string $mode): void
|
||||
{
|
||||
app()->bind(ModuleRecordsController::class, fn () => $mode === 'baseline' ? new BaselineAssociationController() : new ModuleRecordsController());
|
||||
foreach (app('router')->getRoutes() as $route) if ($route->uri() === 'api/module-records') $route->flushController();
|
||||
}
|
||||
|
||||
private function sample(string $area, string $state, int $size, array $expected): array
|
||||
{
|
||||
DB::beginTransaction(); DB::statement("SET LOCAL statement_timeout = '3s'");
|
||||
gc_collect_cycles();
|
||||
$this->sql = []; $this->metrics = ['queries' => 0, 'database_ms' => 0.0];
|
||||
memory_reset_peak_usage(); $base = memory_get_usage(false); $this->start = hrtime(true); $status = 'ok'; $extra = [];
|
||||
try {
|
||||
$r = $this->getJson('/api/module-records?'.http_build_query(['area' => $area, 'state' => $state, 'per_page' => $size]));
|
||||
$elapsed = (hrtime(true) - $this->start) / 1e6; $stats = $this->metrics; $this->metrics = null;
|
||||
$peak = memory_get_peak_usage(false); $allocated = memory_get_peak_usage(true);
|
||||
$r->assertOk()->assertJsonPath('pagination.total', count($expected));
|
||||
$rows = $r->json('records.'.$area); $this->assertSame(array_slice($expected, 0, $size), array_column($rows, 'id'));
|
||||
foreach ($rows as $row) { $this->assertSame($row['id'] % 3 === 1 ? ['commercial'] : [], $row['modules']); $this->assertFalse($row['dependencies_restricted']); }
|
||||
$extra = ['response_sha256' => hash('sha256', $r->getContent()), 'response_bytes' => strlen($r->getContent()), 'rows' => count($rows)];
|
||||
} catch (\Illuminate\Database\QueryException $e) { $status = 'sql_timeout_or_error'; $extra = ['sqlstate' => $e->errorInfo[0] ?? $e->getCode()];
|
||||
} catch (\RuntimeException $e) { if ($e->getMessage() !== 'CASE_BUDGET_REACHED') throw $e; $status = 'case_budget_reached';
|
||||
} finally {
|
||||
$stats ??= $this->metrics; $elapsed ??= (hrtime(true) - $this->start) / 1e6;
|
||||
$peak ??= memory_get_peak_usage(false); $allocated ??= memory_get_peak_usage(true);
|
||||
$this->metrics = null; DB::rollBack();
|
||||
}
|
||||
return ['status' => $status, 'elapsed_ms' => round($elapsed, 3), 'queries' => $stats['queries'], 'database_ms' => round($stats['database_ms'], 3),
|
||||
'peak_php_used_bytes' => $peak, 'incremental_php_used_bytes' => max(0, $peak - $base), 'peak_php_allocated_bytes' => $allocated, ...$extra];
|
||||
}
|
||||
|
||||
private function summarize(array $samples): array
|
||||
{
|
||||
$ok = array_values(array_filter($samples, fn ($s) => $s['status'] === 'ok')); $times = array_column($ok, 'elapsed_ms'); sort($times);
|
||||
return ['completed_samples' => count($ok), 'median_ms' => count($ok) === 3 ? $times[1] : null, 'queries' => $ok[0]['queries'] ?? null,
|
||||
'peak_php_used_bytes' => $ok ? max(array_column($ok, 'peak_php_used_bytes')) : null,
|
||||
'incremental_php_used_bytes' => $ok ? max(array_column($ok, 'incremental_php_used_bytes')) : null,
|
||||
'censored' => count($ok) !== 3, 'last_observed_ms' => end($samples)['elapsed_ms']];
|
||||
}
|
||||
private function brief(array $samples): string { $s = $this->summarize($samples); return $s['censored'] ? 'bounded@'.$s['last_observed_ms'] : $s['median_ms'].'ms'; }
|
||||
|
||||
private function fingerprints(): array
|
||||
{
|
||||
$out = [];
|
||||
foreach (['a_clients', 'a_services', 'a_service_drafts', 'a_billing_drafts', 'identity_audit', 'users', 'permission_grants', 'company_memberships'] as $table) {
|
||||
$out[$table] = (array) DB::selectOne("SELECT count(*) AS rows,md5(coalesce(string_agg(row_to_json(t)::text,E'\\n' ORDER BY row_to_json(t)::text),'')) AS hash FROM {$table} t");
|
||||
}
|
||||
$out['indexes'] = json_encode(DB::select("SELECT indexname,indexdef FROM pg_indexes WHERE schemaname='public' ORDER BY indexname"), JSON_THROW_ON_ERROR);
|
||||
return $out;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,177 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Benchmarks;
|
||||
|
||||
use App\Models\User;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Tests\Support\IdentityFixture;
|
||||
|
||||
require_once __DIR__.'/ModuleAssociationLoadTest.php';
|
||||
|
||||
class ModuleAssociationConcurrencyTest extends ModuleAssociationLoadTest
|
||||
{
|
||||
protected array $jobs = [];
|
||||
protected string $directory;
|
||||
protected int $serial = 0;
|
||||
|
||||
public function test_synthetic_volume_and_query_plans(): void
|
||||
{
|
||||
$run = (string) getenv('SGF_ISOLATED_REGRESSION_RUN');
|
||||
$this->assertMatchesRegularExpression('/^[a-f0-9]{12}$/D', $run);
|
||||
$identity = DB::selectOne("SELECT current_database() AS database,current_user AS role,inet_server_port() AS port,current_setting('cluster_name') AS cluster");
|
||||
$this->assertSame('sgf_tempo_test', $identity->database); $this->assertSame('sgf_test_runner', $identity->role);
|
||||
$this->assertSame('sgf_modular_regression_'.$run, $identity->cluster); $this->assertSame(55439, (int) $identity->port);
|
||||
$tag = bin2hex(random_bytes(6)); $index = 'sgf_qa_read_revision_'.$tag;
|
||||
$this->directory = base_path('../private/association-concurrency-'.$tag); mkdir($this->directory, 0700, true);
|
||||
$before = $this->baseline();
|
||||
$this->assertSame(0, (int) DB::selectOne("SELECT count(*) AS n FROM pg_indexes WHERE schemaname='public' AND tablename='identity_audit' AND indexname <> 'identity_audit_pkey'")->n, 'Unindexed baseline must be explicit');
|
||||
$report = ['date' => '2026-11-02', 'run_tag' => $tag, 'identity' => $identity, 'optimized_sha256' => hash_file('sha256', base_path('app/Modules/Identity/Services/ModuleAssociationReview.php')),
|
||||
'method' => 'Independent PHP processes and synthetic identities, pre-authenticated in-process HTTP kernels, synchronized starts, closed loop of five reads per worker, three waves; no external HTTP/login/browser capacity claim.',
|
||||
'fixture_policy' => 'New synthetic scope committed only in regression DB for subprocess visibility; retained with private manifest. Existing rows preserved, not reset or deleted.', 'phases' => []];
|
||||
$f = $this->fixture(3000, true); $scope = $f['identity'];
|
||||
$fourth = User::create(['name' => 'Consulta carga sintética 4', 'email' => 'assoc-load-'.$tag.'@example.test', 'password' => bin2hex(random_bytes(24))]);
|
||||
DB::table('company_memberships')->insert(['user_id' => $fourth->id, 'tenant_id' => $scope['tenant'], 'company_id' => $scope['company']]);
|
||||
$actors = [$scope['manager']->id, $scope['reader']->id, $scope['limited']->id, $fourth->id];
|
||||
foreach ($actors as $actor) {
|
||||
foreach (['company.view', 'access.manage'] as $permission) IdentityFixture::grant($actor, $scope['tenant'], $scope['company'], $permission);
|
||||
IdentityFixture::grant($actor, $scope['tenant'], $scope['company'], 'unit.view', $scope['unit']);
|
||||
foreach (['clients', 'services', 'service_drafts', 'billing_drafts'] as $area) IdentityFixture::grant($actor, $scope['tenant'], $scope['company'], "$area.view", str_ends_with($area, '_drafts') ? $scope['unit'] : null);
|
||||
}
|
||||
IdentityFixture::grant($actors[0], $scope['tenant'], $scope['company'], 'clients.edit');
|
||||
for ($i = 0; $i < 25; $i++) {
|
||||
$lines = []; foreach (array_slice($f['ids']['services'], $i * 100, 100) as $id) $lines[] = ['service_id' => $id, 'quantity' => '1', 'unit_price' => '10', 'description' => str_repeat('x', 1000)];
|
||||
$data = ['currency' => 'MZN', 'tax_status' => 'pending', 'accounting_status' => 'not_processed', 'lines' => $lines];
|
||||
DB::table('a_service_drafts')->where('id', $f['ids']['service_drafts'][$i])->update(['version' => 2, 'data' => json_encode($data)]);
|
||||
$data['source_version'] = 2; DB::table('a_billing_drafts')->where('id', $f['ids']['billing_drafts'][$i])->update(['version' => 2, 'data' => json_encode($data)]);
|
||||
}
|
||||
foreach (['a_service_drafts', 'a_billing_drafts', 'identity_audit'] as $table) DB::statement('ANALYZE '.$table);
|
||||
$this->actingAs($scope['manager'])->withSession(['auth_version' => $scope['manager']->auth_version]);
|
||||
$context = $this->postJson('/api/context', ['tenant_id' => $scope['tenant'], 'company_id' => $scope['company'], 'unit_id' => $scope['unit'], 'module_id' => null])->assertOk()->json('context.token');
|
||||
$this->withHeader('X-SGF-Context', $context);
|
||||
$workloads = [];
|
||||
foreach (['clients', 'billing_drafts'] as $area) {
|
||||
$url = '/api/module-records?area='.$area.'&per_page=25'; $r = $this->getJson($url)->assertOk()->assertJsonPath('pagination.total', 3000);
|
||||
$workloads[$area] = ['url' => $url, 'total' => 3000, 'response_sha256' => hash('sha256', $r->getContent())];
|
||||
}
|
||||
// Separate processes cannot see uncommitted rows. Only this new QA scope is committed.
|
||||
DB::commit();
|
||||
file_put_contents($this->directory.'/manifest.json', json_encode(['synthetic_only' => true, 'tenant' => $scope['tenant'], 'company' => $scope['company'], 'unit' => $scope['unit'], 'actors' => $actors, 'workloads' => $workloads, 'index_name' => $index], JSON_PRETTY_PRINT | JSON_THROW_ON_ERROR));
|
||||
$created = false;
|
||||
try {
|
||||
foreach (['none', 'revision'] as $mode) {
|
||||
if ($mode === 'revision') {
|
||||
$this->assertNull(DB::selectOne('SELECT to_regclass(?) AS value', [$index])->value);
|
||||
DB::statement("CREATE INDEX $index ON identity_audit (tenant_id,company_id,(details->>'area'),(details->>'record_id'),id DESC) WHERE action IN ('module.record.associated','module.record.disassociated','module.record.reviewed')"); $created = true;
|
||||
DB::statement('ANALYZE identity_audit');
|
||||
}
|
||||
$phase = ['index_mode' => $mode, 'waves' => []];
|
||||
foreach ($workloads as $label => $workload) foreach ([1, 2, 4] as $count) for ($wave = 0; $wave < 3; $wave++) {
|
||||
$phase['waves'][] = ['workload' => $label, 'configured_workers' => $count, ...$this->wave($scope, array_slice($actors, 0, $count), $workload, 5, $tag)];
|
||||
fwrite(STDOUT, "\nCONCURRENT $mode $label workers=$count wave=".($wave + 1)." complete\n");
|
||||
}
|
||||
$report['phases'][] = $phase;
|
||||
}
|
||||
$candidate = array_values(array_filter($f['ids']['clients'], fn ($id) => $id % 3 === 0))[0];
|
||||
$input = ['area' => 'clients', 'id' => $candidate, 'version' => 1, 'operation' => 'associate', 'module_id' => 'commercial', 'reason' => 'Synthetic concurrency rollback only'];
|
||||
$first = $this->postJson('/api/module-records/preview', $input)->assertOk()->json('review_token');
|
||||
$second = $this->postJson('/api/module-records/preview', $input)->assertOk()->json('review_token');
|
||||
$events = DB::table('identity_audit')->where('tenant_id', $scope['tenant'])->where('company_id', $scope['company'])->where('action', 'like', 'module.record.%')->count();
|
||||
DB::beginTransaction();
|
||||
try {
|
||||
$this->postJson('/api/module-records', ['review_token' => $first])->assertOk();
|
||||
$report['mixed_read_wave'] = $this->wave($scope, $actors, $workloads['clients'], 5, $tag);
|
||||
$gate = $this->directory.'/confirm-go';
|
||||
$job = $this->launch(['mode' => 'confirm', 'actor' => $actors[0], 'tenant' => $scope['tenant'], 'company' => $scope['company'], 'unit' => $scope['unit'], 'context' => $context,
|
||||
'review_token' => $second, 'go' => $gate, 'application_name' => 'sgf_assoc_confirm_'.$tag]);
|
||||
$this->ready([$job]); file_put_contents($gate, 'go'); $observed = false; $deadline = microtime(true) + 3;
|
||||
do { DB::select('SELECT pg_stat_clear_snapshot()'); $n = DB::selectOne("SELECT count(*) AS n FROM pg_stat_activity WHERE application_name=? AND wait_event_type='Lock'", ['sgf_assoc_confirm_'.$tag])->n; if ((int) $n > 0) { $observed = true; break; } usleep(25000); } while (microtime(true) < $deadline);
|
||||
$result = $this->collect($job); $this->assertTrue($observed, 'Actual PostgreSQL lock wait must be observed');
|
||||
$this->assertSame('55P03', $result['confirmation']['sqlstate'] ?? null);
|
||||
$report['competing_confirmation'] = ['actual_lock_wait_observed' => $observed, ...$result['confirmation']];
|
||||
} finally { DB::rollBack(); }
|
||||
$this->assertSame($events, DB::table('identity_audit')->where('tenant_id', $scope['tenant'])->where('company_id', $scope['company'])->where('action', 'like', 'module.record.%')->count());
|
||||
$report['synthetic_confirmation_rolled_back'] = true;
|
||||
} finally {
|
||||
foreach ($this->jobs as $job) if (is_resource($job['process'])) { proc_terminate($job['process']); proc_close($job['process']); }
|
||||
while (DB::transactionLevel() > 0) DB::rollBack();
|
||||
if ($created) DB::statement('DROP INDEX '.$index);
|
||||
$this->assertNull(DB::selectOne('SELECT to_regclass(?) AS value', [$index])->value);
|
||||
$report['test_index_removed'] = true;
|
||||
$report['previous_rows_unchanged'] = $this->baseline($before) === $before;
|
||||
$this->assertTrue($report['previous_rows_unchanged']);
|
||||
$output = getenv('SGF_ASSOCIATION_LOAD_OUTPUT');
|
||||
if (!$output || realpath(dirname($output)) !== realpath(base_path('..'))) throw new \RuntimeException('Unexpected report directory');
|
||||
file_put_contents($output, json_encode($report, JSON_PRETTY_PRINT | JSON_THROW_ON_ERROR).PHP_EOL);
|
||||
// Preserve the transaction-based TestCase lifecycle without undoing committed QA setup.
|
||||
DB::beginTransaction();
|
||||
}
|
||||
}
|
||||
|
||||
private function wave(array $scope, array $actors, array $workload, int $requests, string $tag): array
|
||||
{
|
||||
$gate = $this->directory.'/go-'.(++$this->serial); $jobs = []; $startup = microtime(true);
|
||||
foreach ($actors as $i => $actor) $jobs[] = $this->launch(['mode' => 'read', 'actor' => $actor, 'tenant' => $scope['tenant'], 'company' => $scope['company'], 'unit' => $scope['unit'],
|
||||
...$workload, 'requests' => $requests, 'go' => $gate, 'application_name' => 'sgf_assoc_read_'.$tag.'_'.$i]);
|
||||
$this->ready($jobs); $ready = microtime(true); file_put_contents($gate, 'go');
|
||||
$samples = []; $finished = $ready;
|
||||
foreach ($jobs as $job) { $result = $this->collect($job); $samples = [...$samples, ...$result['requests']]; $finished = max($finished, $result['finished_at']); }
|
||||
$ok = array_values(array_filter($samples, fn ($s) => $s['status'] === 'ok')); $events = [];
|
||||
foreach ($ok as $s) { $events[] = [$s['started_at'], 1]; $events[] = [$s['finished_at'], -1]; }
|
||||
usort($events, fn ($a, $b) => ($a[0] <=> $b[0]) ?: ($a[1] <=> $b[1])); $active = 0; $max = 0;
|
||||
foreach ($events as $event) { $active += $event[1]; $max = max($max, $active); }
|
||||
$this->assertLessThanOrEqual(count($actors), $max); $this->assertGreaterThanOrEqual(1, $max);
|
||||
return ['startup_to_ready_ms' => round(($ready - $startup) * 1000, 3), 'measurement_window_ms' => round(($finished - $ready) * 1000, 3), 'observed_max_inflight' => $max,
|
||||
'complete_requests' => count($ok), 'limited_requests' => count($samples) - count($ok), 'samples' => $samples];
|
||||
}
|
||||
|
||||
protected function launch(array $input): array
|
||||
{
|
||||
$stem = $this->directory.'/worker-'.(++$this->serial);
|
||||
$input += ['ready' => $stem.'.ready', 'result' => $stem.'.json'];
|
||||
file_put_contents($stem.'.input.json', json_encode($input, JSON_THROW_ON_ERROR));
|
||||
$env = getenv(); $env['SGF_ASSOCIATION_WORKER_INPUT'] = $stem.'.input.json';
|
||||
$pipes = []; $process = proc_open([PHP_BINARY, 'vendor/phpunit/phpunit/phpunit', 'scripts/benchmarks/ModuleAssociationReadWorkerTest.php', '--no-progress'],
|
||||
[0 => ['pipe', 'r'], 1 => ['file', $stem.'.stdout', 'w'], 2 => ['file', $stem.'.stderr', 'w']], $pipes, base_path(), $env, ['bypass_shell' => true]);
|
||||
$this->assertIsResource($process); fclose($pipes[0]);
|
||||
$job = ['process' => $process, 'input' => $input, 'stdout' => $stem.'.stdout', 'stderr' => $stem.'.stderr']; $this->jobs[] = $job; return $job;
|
||||
}
|
||||
|
||||
protected function ready(array $jobs): void
|
||||
{
|
||||
$deadline = microtime(true) + 30;
|
||||
do {
|
||||
$all = true;
|
||||
foreach ($jobs as $job) if (!is_file($job['input']['ready'])) { $all = false; if (!proc_get_status($job['process'])['running']) throw new \RuntimeException('Worker failed before ready: '.file_get_contents($job['stdout']).file_get_contents($job['stderr'])); }
|
||||
if ($all) return; usleep(10000);
|
||||
} while (microtime(true) < $deadline);
|
||||
throw new \RuntimeException('Workers did not reach barrier');
|
||||
}
|
||||
|
||||
protected function collect(array $job): array
|
||||
{
|
||||
$deadline = microtime(true) + 60;
|
||||
do { $status = proc_get_status($job['process']); if (!$status['running']) break; usleep(10000); } while (microtime(true) < $deadline);
|
||||
if ($status['running']) throw new \RuntimeException('Worker deadline reached');
|
||||
$exit = proc_close($job['process']); if ($exit === -1) $exit = $status['exitcode'];
|
||||
$this->assertSame(0, $exit, file_get_contents($job['stdout']).file_get_contents($job['stderr']));
|
||||
return json_decode(file_get_contents($job['input']['result']), true, 512, JSON_THROW_ON_ERROR);
|
||||
}
|
||||
|
||||
protected function baseline(?array $old = null): array
|
||||
{
|
||||
$out = [];
|
||||
foreach (['a_clients', 'a_services', 'a_service_drafts', 'a_billing_drafts', 'identity_audit', 'users', 'permission_grants', 'company_memberships'] as $table) {
|
||||
$key = $table === 'company_memberships' ? 'user_id' : 'id';
|
||||
$q = DB::table($table);
|
||||
// IDs are captured integer primary keys, not user input. Avoid the
|
||||
// PostgreSQL bind-parameter ceiling in accumulated QA fixtures.
|
||||
if ($old !== null) $q->whereIntegerInRaw($key, $old[$table]['ids']);
|
||||
$q->orderBy($key);
|
||||
if ($table === 'company_memberships') $q->orderBy('tenant_id')->orderBy('company_id');
|
||||
$rows = $q->get();
|
||||
$out[$table] = ['ids' => $old[$table]['ids'] ?? $rows->pluck($key)->unique()->values()->all(), 'hash' => hash('sha256', $rows->toJson())];
|
||||
}
|
||||
$out['indexes'] = json_encode(DB::select("SELECT indexname,indexdef FROM pg_indexes WHERE schemaname='public' ORDER BY indexname"), JSON_THROW_ON_ERROR);
|
||||
return $out;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,185 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Benchmarks;
|
||||
|
||||
use Illuminate\Database\Events\QueryExecuted;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use App\Modules\Identity\Http\ModuleRecordsController;
|
||||
use App\Modules\Identity\Services\PreProjectionModuleAssociationReview;
|
||||
|
||||
require_once __DIR__.'/ModuleAssociationLoadTest.php';
|
||||
|
||||
final class PreProjectionAssociationController
|
||||
{
|
||||
public function index(Request $r) { return response()->json(app(PreProjectionModuleAssociationReview::class)->listing($r))->header('Cache-Control', 'no-store'); }
|
||||
}
|
||||
|
||||
final class ModuleAssociationFanoutTest extends ModuleAssociationLoadTest
|
||||
{
|
||||
private ?array $metrics = null;
|
||||
private ?array $plan = null;
|
||||
private int $start = 0;
|
||||
|
||||
public function test_synthetic_volume_and_query_plans(): void
|
||||
{
|
||||
$run = (string) getenv('SGF_ISOLATED_REGRESSION_RUN');
|
||||
$this->assertMatchesRegularExpression('/^[a-f0-9]{12}$/D', $run);
|
||||
$identity = DB::selectOne("SELECT current_database() AS database,current_user AS role,inet_server_port() AS port,current_setting('cluster_name') AS cluster");
|
||||
$this->assertSame('sgf_tempo_test', $identity->database); $this->assertSame('sgf_test_runner', $identity->role);
|
||||
$this->assertSame('sgf_modular_regression_'.$run, $identity->cluster); $this->assertSame(55439, (int) $identity->port);
|
||||
$this->assertSame('8.4.27', PHP_VERSION);
|
||||
require_once base_path('../pre-projection-review.php');
|
||||
$this->withoutExceptionHandling();
|
||||
$before = $this->fingerprints();
|
||||
$report = ['date' => '2026-11-02', 'identity' => $identity, 'optimized_sha256' => hash_file('sha256', base_path('app/Modules/Identity/Services/ModuleAssociationReview.php')),
|
||||
'pre_projection_sha256' => trim(file_get_contents(base_path('../pre-projection-sha256.txt'))),
|
||||
'php_memory_limit' => ini_get('memory_limit'), 'records_per_area' => 10000, 'lines_per_draft' => 100,
|
||||
'bounds' => ['request_ms' => 15000, 'statement_ms' => 3000, 'queries' => 250, 'php_used_bytes' => 201326592],
|
||||
'method' => 'New synthetic fixtures, three sequential warm in-process HTTP samples; same profile across no-index/revision-only/pair phases; no product changes or deployment capacity claim.', 'profiles' => []];
|
||||
DB::listen(function (QueryExecuted $q) {
|
||||
if ($this->metrics === null) return;
|
||||
if (!preg_match('/^\s*(select|with)\b/i', $q->sql)) throw new \RuntimeException('READ_WORKLOAD_WROTE_SQL');
|
||||
$this->metrics['queries']++; $this->metrics['database_ms'] += $q->time;
|
||||
if (str_contains($q->sql, 'identity_audit') && ($this->plan === null || str_contains($q->sql, 'from "a_clients"'))) $this->plan = ['sql' => $q->sql, 'bindings' => $q->bindings];
|
||||
if ((hrtime(true) - $this->start) / 1e6 > 15000 || $this->metrics['queries'] > 250 || memory_get_usage(false) > 201326592) throw new \RuntimeException('DIAGNOSTIC_BUDGET_REACHED');
|
||||
});
|
||||
DB::beginTransaction();
|
||||
try {
|
||||
$f = $this->fixture(10000, true); $actor = $f['identity'];
|
||||
$this->actingAs($actor['manager'])->withSession(['auth_version' => $actor['manager']->auth_version]);
|
||||
$token = $this->postJson('/api/context', ['tenant_id' => $actor['tenant'], 'company_id' => $actor['company'], 'unit_id' => $actor['unit'], 'module_id' => null])->assertOk()->json('context.token');
|
||||
$this->withHeader('X-SGF-Context', $token);
|
||||
foreach ([128, 1000] as $descriptionBytes) {
|
||||
$version = $this->expand($f, $descriptionBytes);
|
||||
$profile = ['line_description_bytes' => $descriptionBytes, 'draft_version' => $version, 'variants' => []];
|
||||
$cases = [];
|
||||
foreach (['service_drafts', 'billing_drafts'] as $area) foreach ([25, 100] as $size) $cases[] = [$area, 'all', $size];
|
||||
if ($descriptionBytes === 128) foreach (['pending', 'associated', 'removed'] as $state) $cases[] = ['clients', $state, 25];
|
||||
$hashes = [];
|
||||
foreach (['pre_projection', 'none', 'revision_only', 'pair'] as $mode) {
|
||||
$this->controller($mode);
|
||||
DB::beginTransaction();
|
||||
try {
|
||||
$ddl = [];
|
||||
if (in_array($mode, ['revision_only', 'pair'], true)) $ddl[] = "CREATE INDEX sgf_qa_assoc_revision ON identity_audit (tenant_id,company_id,(details->>'area'),(details->>'record_id'),id DESC) WHERE action IN ('module.record.associated','module.record.disassociated','module.record.reviewed')";
|
||||
if ($mode === 'pair') $ddl[] = "CREATE INDEX sgf_qa_assoc_links ON identity_audit (tenant_id,company_id,(details->>'area'),(details->>'record_id'),(details->>'module_id'),id DESC) WHERE action IN ('module.record.associated','module.record.disassociated')";
|
||||
$start = hrtime(true);
|
||||
foreach ($ddl as $sql) DB::statement($sql);
|
||||
if ($ddl) DB::statement('ANALYZE identity_audit');
|
||||
$variant = ['ddl' => $ddl, 'index_build_and_analyze_ms' => round((hrtime(true) - $start) / 1e6, 3), 'index_bytes' => [], 'cases' => []];
|
||||
foreach (['sgf_qa_assoc_revision', 'sgf_qa_assoc_links'] as $name) {
|
||||
$bytes = DB::selectOne('SELECT pg_relation_size(to_regclass(?)) AS bytes', [$name])->bytes;
|
||||
if ($bytes !== null) $variant['index_bytes'][$name] = (int) $bytes;
|
||||
}
|
||||
foreach ($cases as [$area, $state, $size]) {
|
||||
$expected = array_values(array_filter($f['ids'][$area], fn ($id) => match ($state) { 'pending' => $id % 3 === 0, 'associated' => $id % 3 === 1, 'removed' => $id % 3 === 2, default => true }));
|
||||
$this->getJson('/api/module-records?area='.$area.'&per_page=1')->assertOk();
|
||||
$case = ['area' => $area, 'state' => $state, 'page_size' => $size, 'samples' => []];
|
||||
for ($i = 0; $i < 3; $i++) {
|
||||
$sample = $this->sample($f, $area, $state, $size, $expected, $version);
|
||||
$case['samples'][] = $sample;
|
||||
if ($sample['status'] !== 'ok') break;
|
||||
$hashes["$area/$state/$size"][] = $sample['response_sha256'];
|
||||
}
|
||||
$ok = array_values(array_filter($case['samples'], fn ($s) => $s['status'] === 'ok'));
|
||||
$times = array_column($ok, 'elapsed_ms'); sort($times);
|
||||
$case['median_ms'] = count($ok) === 3 ? $times[1] : null;
|
||||
$case['complete'] = count($ok) === 3;
|
||||
$case['planned_indexes'] = $this->plannedIndexes();
|
||||
$variant['cases'][] = $case;
|
||||
fwrite(STDOUT, "\nFANOUT $descriptionBytes $mode $area/$state/$size ".($case['median_ms'] ?? 'bounded')."ms\n");
|
||||
}
|
||||
$profile['variants'][$mode] = $variant;
|
||||
} finally { $this->metrics = null; DB::rollBack(); }
|
||||
$this->assertSame(0, (int) DB::selectOne("SELECT count(*) AS n FROM pg_indexes WHERE schemaname='public' AND indexname LIKE 'sgf_qa_assoc_%'")->n);
|
||||
}
|
||||
foreach ($hashes as $values) $this->assertCount(1, array_unique($values), 'All complete index variants must preserve the same response');
|
||||
$profile['complete_responses_identical'] = true;
|
||||
$report['profiles'][] = $profile;
|
||||
}
|
||||
} finally {
|
||||
$this->metrics = null; DB::rollBack();
|
||||
$report['original_test_data_and_indexes_preserved'] = $before === $this->fingerprints();
|
||||
$output = getenv('SGF_ASSOCIATION_LOAD_OUTPUT');
|
||||
if (!$output || realpath(dirname($output)) !== realpath(base_path('..'))) throw new \RuntimeException('Unexpected report directory');
|
||||
file_put_contents($output, json_encode($report, JSON_PRETTY_PRINT | JSON_THROW_ON_ERROR).PHP_EOL);
|
||||
}
|
||||
$this->assertTrue($report['original_test_data_and_indexes_preserved']);
|
||||
}
|
||||
|
||||
private function controller(string $mode): void
|
||||
{
|
||||
app()->bind(ModuleRecordsController::class, fn () => $mode === 'pre_projection' ? new PreProjectionAssociationController() : new ModuleRecordsController());
|
||||
foreach (app('router')->getRoutes() as $route) if ($route->uri() === 'api/module-records') $route->flushController();
|
||||
}
|
||||
|
||||
private function expand(array $f, int $descriptionBytes): int
|
||||
{
|
||||
$versions = DB::table('a_service_drafts')->whereIn('id', array_slice($f['ids']['service_drafts'], 0, 100))->pluck('version', 'id');
|
||||
$version = (int) $versions[$f['ids']['service_drafts'][0]] + 1;
|
||||
for ($i = 0; $i < 100; $i++) {
|
||||
$lines = [];
|
||||
foreach (array_slice($f['ids']['services'], $i * 100, 100) as $id) $lines[] = ['service_id' => $id, 'quantity' => '1', 'unit_price' => '10', 'description' => str_pad('SYNTHETIC-WIDE-', $descriptionBytes, 'x')];
|
||||
$data = ['currency' => 'MZN', 'tax_status' => 'pending', 'accounting_status' => 'not_processed', 'lines' => $lines];
|
||||
DB::table('a_service_drafts')->where('id', $f['ids']['service_drafts'][$i])->update(['version' => $version, 'data' => json_encode($data, JSON_THROW_ON_ERROR), 'updated_at' => now()]);
|
||||
$data['source_version'] = $version;
|
||||
DB::table('a_billing_drafts')->where('id', $f['ids']['billing_drafts'][$i])->update(['version' => $version, 'data' => json_encode($data, JSON_THROW_ON_ERROR), 'updated_at' => now()]);
|
||||
}
|
||||
foreach (['a_service_drafts', 'a_billing_drafts'] as $table) DB::statement('ANALYZE '.$table);
|
||||
return $version;
|
||||
}
|
||||
|
||||
private function sample(array $f, string $area, string $state, int $size, array $expected, int $version): array
|
||||
{
|
||||
DB::beginTransaction(); DB::statement("SET LOCAL statement_timeout = '3s'"); gc_collect_cycles();
|
||||
$this->plan = null; $this->metrics = ['queries' => 0, 'database_ms' => 0.0];
|
||||
memory_reset_peak_usage(); $base = memory_get_usage(false); $this->start = hrtime(true); $extra = []; $status = 'ok';
|
||||
try {
|
||||
$r = $this->getJson('/api/module-records?'.http_build_query(['area' => $area, 'state' => $state, 'per_page' => $size]));
|
||||
$elapsed = (hrtime(true) - $this->start) / 1e6; $stats = $this->metrics; $this->metrics = null;
|
||||
$peak = memory_get_peak_usage(false); $allocated = memory_get_peak_usage(true);
|
||||
$r->assertOk()->assertJsonPath('pagination.total', count($expected));
|
||||
$rows = $r->json('records.'.$area); $this->assertSame(array_slice($expected, 0, $size), array_column($rows, 'id'));
|
||||
$positions = array_flip(array_slice($f['ids'][$area], 0, 100));
|
||||
foreach ($rows as $row) {
|
||||
$this->assertFalse($row['dependencies_restricted']);
|
||||
if ($area === 'clients') continue;
|
||||
$this->assertSame($version, $row['version']);
|
||||
$this->assertCount($area === 'billing_drafts' ? 102 : 101, $row['dependencies']);
|
||||
$serviceIds = array_column(array_values(array_filter($row['dependencies'], fn ($d) => $d['area'] === 'services')), 'id');
|
||||
$this->assertSame(array_slice($f['ids']['services'], $positions[$row['id']] * 100, 100), $serviceIds);
|
||||
}
|
||||
$this->assertStringNotContainsString('SYNTHETIC-WIDE-', $r->getContent(), 'Document contents must not leak into administrative metadata');
|
||||
$extra = ['rows' => count($rows), 'response_bytes' => strlen($r->getContent()), 'response_sha256' => hash('sha256', $r->getContent())];
|
||||
} catch (\Illuminate\Database\QueryException $e) { $status = 'sql_timeout_or_error'; $extra = ['sqlstate' => $e->errorInfo[0] ?? $e->getCode()];
|
||||
} catch (\RuntimeException $e) { if ($e->getMessage() !== 'DIAGNOSTIC_BUDGET_REACHED') throw $e; $status = 'diagnostic_budget_reached';
|
||||
} finally {
|
||||
$elapsed ??= (hrtime(true) - $this->start) / 1e6; $stats ??= $this->metrics; $peak ??= memory_get_peak_usage(false); $allocated ??= memory_get_peak_usage(true);
|
||||
$this->metrics = null; DB::rollBack();
|
||||
}
|
||||
return ['status' => $status, 'elapsed_ms' => round($elapsed, 3), 'queries' => $stats['queries'], 'database_ms' => round($stats['database_ms'], 3),
|
||||
'incremental_php_used_bytes' => max(0, $peak - $base), 'peak_php_used_bytes' => $peak, 'peak_php_allocated_bytes' => $allocated, ...$extra];
|
||||
}
|
||||
|
||||
private function plannedIndexes(): array
|
||||
{
|
||||
if (!$this->plan) return [];
|
||||
DB::beginTransaction(); DB::statement("SET LOCAL statement_timeout = '3s'");
|
||||
try {
|
||||
$raw = DB::select('EXPLAIN (FORMAT JSON) '.$this->plan['sql'], $this->plan['bindings']);
|
||||
$plan = json_decode($raw[0]->{'QUERY PLAN'}, true, 512, JSON_THROW_ON_ERROR)[0]['Plan']; $names = [];
|
||||
$visit = function (array $node) use (&$visit, &$names): void { if (isset($node['Index Name'])) $names[] = $node['Index Name']; foreach ($node['Plans'] ?? [] as $child) $visit($child); };
|
||||
$visit($plan); $visit = null;
|
||||
return array_values(array_unique($names));
|
||||
} finally { DB::rollBack(); }
|
||||
}
|
||||
|
||||
private function fingerprints(): array
|
||||
{
|
||||
$out = [];
|
||||
foreach (['a_clients', 'a_services', 'a_service_drafts', 'a_billing_drafts', 'identity_audit', 'users', 'permission_grants', 'company_memberships'] as $table) $out[$table] = (array) DB::selectOne("SELECT count(*) AS rows,md5(coalesce(string_agg(row_to_json(t)::text,E'\\n' ORDER BY row_to_json(t)::text),'')) AS hash FROM {$table} t");
|
||||
$out['indexes'] = json_encode(DB::select("SELECT indexname,indexdef FROM pg_indexes WHERE schemaname='public' ORDER BY indexname"), JSON_THROW_ON_ERROR);
|
||||
return $out;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Benchmarks;
|
||||
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Tests\Support\IdentityFixture;
|
||||
|
||||
require_once __DIR__.'/ModuleAssociationConcurrencyTest.php';
|
||||
|
||||
final class ModuleAssociationHttpLockTest extends ModuleAssociationConcurrencyTest
|
||||
{
|
||||
public function test_synthetic_volume_and_query_plans(): void
|
||||
{
|
||||
$run = (string) getenv('SGF_ISOLATED_REGRESSION_RUN');
|
||||
$this->assertMatchesRegularExpression('/^[a-f0-9]{12}$/D', $run);
|
||||
$identity = DB::selectOne("SELECT current_database() AS database,current_user AS role,inet_server_port() AS port,current_setting('cluster_name') AS cluster");
|
||||
$this->assertSame('sgf_tempo_test', $identity->database); $this->assertSame('sgf_test_runner', $identity->role);
|
||||
$this->assertSame('sgf_modular_regression_'.$run, $identity->cluster); $this->assertSame(55439, (int) $identity->port);
|
||||
$tag = bin2hex(random_bytes(6)); $this->directory = base_path('../private/association-http-lock-'.$tag); mkdir($this->directory, 0700, true);
|
||||
$before = $this->baseline(); $scope = IdentityFixture::create('-http-lock-'.$tag);
|
||||
foreach (['clients.view', 'clients.edit'] as $permission) IdentityFixture::grant($scope['manager']->id, $scope['tenant'], $scope['company'], $permission);
|
||||
$cases = ['confirm_rollback', 'confirm_commit', 'preview_rollback']; $ids = [];
|
||||
foreach ($cases as $name) $ids[$name] = DB::table('a_clients')->insertGetId(['tenant_id' => $scope['tenant'], 'company_id' => $scope['company'], 'unit_id' => null,
|
||||
'name' => 'Synthetic HTTP lock '.$name, 'version' => 1, 'data' => '{}', 'created_at' => now(), 'updated_at' => now()]);
|
||||
$this->actingAs($scope['manager'])->withSession(['auth_version' => $scope['manager']->auth_version]);
|
||||
$context = $this->postJson('/api/context', ['tenant_id' => $scope['tenant'], 'company_id' => $scope['company'], 'unit_id' => $scope['unit'], 'module_id' => null])->assertOk()->json('context.token');
|
||||
$this->withHeader('X-SGF-Context', $context); DB::commit();
|
||||
file_put_contents($this->directory.'/manifest.json', json_encode(['tenant' => $scope['tenant'], 'company' => $scope['company'], 'clients' => $ids], JSON_THROW_ON_ERROR));
|
||||
$report = ['identity' => $identity, 'run_tag' => $tag, 'source_sha256' => hash_file('sha256', base_path('app/Modules/Identity/Services/ModuleAssociationReview.php')),
|
||||
'controller_sha256' => hash_file('sha256', base_path('app/Modules/Identity/Http/ModuleRecordsController.php')), 'cases' => []];
|
||||
try {
|
||||
foreach ($cases as $number => $name) {
|
||||
$id = $ids[$name]; $input = ['area' => 'clients', 'id' => $id, 'version' => 1, 'module_id' => 'commercial', 'operation' => 'associate', 'reason' => 'Synthetic HTTP lock recovery'];
|
||||
$first = $this->postJson('/api/module-records/preview', $input)->assertOk()->json('review_token');
|
||||
$second = $this->postJson('/api/module-records/preview', $input)->assertOk()->json('review_token');
|
||||
DB::beginTransaction();
|
||||
try {
|
||||
$this->postJson('/api/module-records', ['review_token' => $first])->assertOk();
|
||||
$gate = $this->directory.'/go-'.$number;
|
||||
$application = 'sgf_assoc_http_'.$tag.'_'.$number;
|
||||
$parent = (int) DB::selectOne('SELECT pg_backend_pid() AS pid')->pid;
|
||||
$job = $this->launch(['mode' => $name === 'preview_rollback' ? 'preview_http' : 'confirm_http', 'lock_timeout_ms' => 1000,
|
||||
'actor' => $scope['manager']->id, 'tenant' => $scope['tenant'], 'company' => $scope['company'], 'unit' => $scope['unit'],
|
||||
'context' => $context, 'review_token' => $second, 'preview_input' => $input, 'go' => $gate, 'application_name' => $application]);
|
||||
$this->ready([$job]); file_put_contents($gate, 'go');
|
||||
$deadline = microtime(true) + 3; $wait = null;
|
||||
do {
|
||||
DB::select('SELECT pg_stat_clear_snapshot()');
|
||||
$wait = DB::selectOne("SELECT wait_event,to_json(pg_blocking_pids(pid)) AS blockers FROM pg_stat_activity WHERE application_name=? AND wait_event_type='Lock'", [$application]);
|
||||
if (!$wait) usleep(10000);
|
||||
} while (!$wait && microtime(true) < $deadline);
|
||||
$this->assertNotNull($wait); $this->assertContains($parent, json_decode($wait->blockers, true));
|
||||
$result = $this->collect($job)['confirmation'];
|
||||
$this->assertSame(423, $result['http_status']); $this->assertSame('association_busy', $result['code']);
|
||||
if ($name === 'confirm_commit') DB::commit(); else DB::rollBack();
|
||||
$count = fn () => DB::table('identity_audit')->where('tenant_id', $scope['tenant'])->where('company_id', $scope['company'])->where('action', 'like', 'module.record.%')
|
||||
->whereRaw("details::jsonb->>'area'='clients' AND details::jsonb->>'record_id'=?", [(string) $id])->count();
|
||||
$this->assertSame($name === 'confirm_commit' ? 1 : 0, $count());
|
||||
if ($name === 'confirm_commit') {
|
||||
$this->postJson('/api/module-records', ['review_token' => $second])->assertConflict();
|
||||
$this->postJson('/api/module-records/preview', $input)->assertOk();
|
||||
$recovery = '409_then_new_preview';
|
||||
} else {
|
||||
$this->postJson('/api/module-records/preview', $input)->assertOk();
|
||||
$event = $this->postJson('/api/module-records', ['review_token' => $second])->assertOk()->assertJsonPath('replayed', false)->json('event_id');
|
||||
$this->postJson('/api/module-records', ['review_token' => $second])->assertOk()->assertJsonPath('replayed', true)->assertJsonPath('event_id', $event);
|
||||
$recovery = '200_then_idempotent_replay';
|
||||
}
|
||||
$this->assertSame(1, $count());
|
||||
$report['cases'][] = ['case' => $name, 'wait_event' => $wait->wait_event, 'blocking_parent_verified' => true, 'timeout' => $result,
|
||||
'recovery' => $recovery, 'decision_events_after_retry' => $count(), 'duplicate_decision' => false];
|
||||
fwrite(STDOUT, "\nHTTP LOCK $name status=423 recovery=$recovery events=1\n");
|
||||
} finally { while (DB::transactionLevel() > 0) DB::rollBack(); }
|
||||
}
|
||||
$report['completed'] = true;
|
||||
} finally {
|
||||
foreach ($this->jobs as $job) if (is_resource($job['process'])) { proc_terminate($job['process']); proc_close($job['process']); }
|
||||
while (DB::transactionLevel() > 0) DB::rollBack();
|
||||
$report['previous_qa_rows_and_index_catalog_preserved'] = $this->baseline($before) === $before;
|
||||
$this->assertTrue($report['previous_qa_rows_and_index_catalog_preserved']);
|
||||
$output = getenv('SGF_ASSOCIATION_LOAD_OUTPUT');
|
||||
if (!$output || realpath(dirname($output)) !== realpath(base_path('..'))) throw new \RuntimeException('Unexpected report directory');
|
||||
file_put_contents($output, json_encode($report, JSON_PRETTY_PRINT | JSON_THROW_ON_ERROR).PHP_EOL);
|
||||
DB::beginTransaction();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Benchmarks;
|
||||
|
||||
use Illuminate\Database\Events\QueryExecuted;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
require_once __DIR__.'/ModuleAssociationLoadTest.php';
|
||||
|
||||
final class ModuleAssociationIndexPlanTest extends ModuleAssociationLoadTest
|
||||
{
|
||||
public function test_synthetic_volume_and_query_plans(): void
|
||||
{
|
||||
$run = (string) getenv('SGF_ISOLATED_REGRESSION_RUN');
|
||||
$this->assertMatchesRegularExpression('/^[a-f0-9]{12}$/D', $run);
|
||||
$identity = DB::selectOne("SELECT current_database() AS database,current_user AS role,inet_server_port() AS port,current_setting('cluster_name') AS cluster");
|
||||
$this->assertSame('sgf_tempo_test', $identity->database); $this->assertSame('sgf_test_runner', $identity->role);
|
||||
$this->assertSame('sgf_modular_regression_'.$run, $identity->cluster); $this->assertSame(55439, (int) $identity->port);
|
||||
$f = $this->fixture(10000, true); $actor = $f['identity'];
|
||||
$this->actingAs($actor['manager'])->withSession(['auth_version' => $actor['manager']->auth_version]);
|
||||
$token = $this->postJson('/api/context', ['tenant_id' => $actor['tenant'], 'company_id' => $actor['company'], 'unit_id' => $actor['unit'], 'module_id' => null])->assertOk()->json('context.token');
|
||||
$this->withHeader('X-SGF-Context', $token);
|
||||
$capturing = false; $queries = [];
|
||||
DB::listen(function (QueryExecuted $q) use (&$capturing, &$queries) { if ($capturing && str_contains($q->sql, 'identity_audit')) $queries[hash('sha256', $q->sql)] = ['sql' => $q->sql, 'bindings' => $q->bindings]; });
|
||||
$report = ['date' => '2026-11-02', 'identity' => $identity, 'records_per_area' => 10000, 'variants' => []];
|
||||
foreach (['batch', 'indexed_batch'] as $mode) {
|
||||
DB::beginTransaction();
|
||||
try {
|
||||
if ($mode === 'indexed_batch') {
|
||||
DB::statement("CREATE INDEX sgf_qa_assoc_links ON identity_audit (tenant_id,company_id,(details->>'area'),(details->>'record_id'),(details->>'module_id'),id DESC) WHERE action IN ('module.record.associated','module.record.disassociated')");
|
||||
DB::statement("CREATE INDEX sgf_qa_assoc_revision ON identity_audit (tenant_id,company_id,(details->>'area'),(details->>'record_id'),id DESC) WHERE action IN ('module.record.associated','module.record.disassociated','module.record.reviewed')");
|
||||
DB::statement('ANALYZE identity_audit');
|
||||
}
|
||||
$queries = []; $capturing = true;
|
||||
$this->getJson('/api/module-records?area=billing_drafts&per_page=100')->assertOk();
|
||||
$this->getJson('/api/module-records?area=clients&state=pending&per_page=25')->assertOk();
|
||||
$capturing = false;
|
||||
foreach ($queries as $signature => $q) {
|
||||
DB::beginTransaction(); DB::statement("SET LOCAL statement_timeout = '3s'");
|
||||
try {
|
||||
$raw = DB::select('EXPLAIN (ANALYZE,BUFFERS,FORMAT JSON) '.$q['sql'], $q['bindings']);
|
||||
$plan = json_decode($raw[0]->{'QUERY PLAN'}, true, 512, JSON_THROW_ON_ERROR)[0]; $nodes = [];
|
||||
$this->nodesOnly($plan['Plan'], $nodes);
|
||||
$report['variants'][$mode][] = ['signature' => $signature, 'shape' => $q['sql'], 'execution_ms' => $plan['Execution Time'], 'nodes' => $nodes];
|
||||
} catch (\Illuminate\Database\QueryException $e) { $report['variants'][$mode][] = ['signature' => $signature, 'status' => 'plan_timeout', 'sqlstate' => $e->errorInfo[0] ?? $e->getCode()];
|
||||
} finally { DB::rollBack(); }
|
||||
}
|
||||
} finally { $capturing = false; DB::rollBack(); }
|
||||
}
|
||||
$this->assertSame(0, (int) DB::selectOne("SELECT count(*) AS n FROM pg_indexes WHERE schemaname='public' AND indexname LIKE 'sgf_qa_assoc_%'")->n);
|
||||
$report['indexes_rolled_back'] = true;
|
||||
$output = getenv('SGF_ASSOCIATION_LOAD_OUTPUT');
|
||||
if (!$output || realpath(dirname($output)) !== realpath(base_path('..'))) throw new \RuntimeException('Unexpected report directory');
|
||||
file_put_contents($output, json_encode($report, JSON_PRETTY_PRINT | JSON_THROW_ON_ERROR).PHP_EOL);
|
||||
}
|
||||
|
||||
private function nodesOnly(array $node, array &$out): void
|
||||
{
|
||||
$out[] = array_intersect_key($node, array_flip(['Node Type', 'Relation Name', 'Index Name', 'Actual Rows', 'Actual Loops', 'Rows Removed by Filter', 'Shared Hit Blocks', 'Shared Read Blocks', 'Temp Read Blocks', 'Temp Written Blocks']));
|
||||
foreach ($node['Plans'] ?? [] as $child) $this->nodesOnly($child, $out);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,192 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Benchmarks;
|
||||
|
||||
use Illuminate\Database\Events\QueryExecuted;
|
||||
use Illuminate\Foundation\Testing\DatabaseTransactions;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Tests\Support\IdentityFixture;
|
||||
use Tests\TestCase;
|
||||
|
||||
// Explicit invocation only: outside the default test directories. Never migrate.
|
||||
class ModuleAssociationLoadTest extends TestCase
|
||||
{
|
||||
use DatabaseTransactions;
|
||||
private ?array $measurement = null;
|
||||
private array $queries = [];
|
||||
private int $started = 0;
|
||||
private array $report = [];
|
||||
|
||||
public function test_synthetic_volume_and_query_plans(): void
|
||||
{
|
||||
$this->assertSame('8.4.27', PHP_VERSION);
|
||||
$run = (string) getenv('SGF_ISOLATED_REGRESSION_RUN');
|
||||
$this->assertMatchesRegularExpression('/^[a-f0-9]{12}$/D', $run);
|
||||
$identity = DB::selectOne("SELECT current_database() AS database,current_user AS role,inet_server_port() AS port,current_setting('cluster_name') AS cluster");
|
||||
$this->assertSame('sgf_modular_regression_'.$run, $identity->cluster);
|
||||
$this->assertSame(55439, (int) $identity->port);
|
||||
$this->withoutExceptionHandling();
|
||||
DB::listen(function (QueryExecuted $q) {
|
||||
if ($this->measurement === null) return;
|
||||
$this->measurement['queries']++;
|
||||
$this->measurement['database_ms'] += $q->time;
|
||||
if (!preg_match('/^\s*(select|with)\b/i', $q->sql)) throw new \RuntimeException('READ_WORKLOAD_WROTE_SQL');
|
||||
$key = hash('sha256', $q->sql);
|
||||
$entry = $this->queries[$key] ?? ['sql' => $q->sql, 'bindings' => $q->bindings, 'count' => 0, 'total_ms' => 0.0];
|
||||
$entry['count']++; $entry['total_ms'] += $q->time; $this->queries[$key] = $entry;
|
||||
if ((hrtime(true) - $this->started) / 1e6 > 15000 || $this->measurement['queries'] > 2500) throw new \RuntimeException('CASE_BUDGET_REACHED');
|
||||
});
|
||||
$this->report = ['date' => '2026-11-02', 'checkpoint' => '340f3bb', 'php' => PHP_VERSION, 'identity' => $identity,
|
||||
'method' => 'Sequential in-process HTTP kernel requests; three measured repetitions after warm-up; fixtures rolled back; no external HTTP/browser or concurrent capacity claim.',
|
||||
'bounds' => ['sql_timeout_ms' => 3000, 'case_budget_ms' => 15000, 'query_budget' => 2500], 'datasets' => []];
|
||||
try {
|
||||
foreach ([1000, 5000, 10000] as $size) {
|
||||
DB::beginTransaction();
|
||||
try {
|
||||
$fixture = $this->fixture($size);
|
||||
$f = $fixture['identity'];
|
||||
$this->actingAs($f['manager'])->withSession(['auth_version' => $f['manager']->auth_version]);
|
||||
$token = $this->postJson('/api/context', ['tenant_id' => $f['tenant'], 'company_id' => $f['company'], 'unit_id' => $f['unit'], 'module_id' => null])->assertOk()->json('context.token');
|
||||
$this->withHeader('X-SGF-Context', $token);
|
||||
$this->getJson('/api/module-records?per_page=1')->assertOk();
|
||||
$dataset = ['clients' => $size, 'services' => count($fixture['ids']['services']), 'drafts_per_area' => count($fixture['ids']['service_drafts']),
|
||||
'module_events' => DB::table('identity_audit')->where('tenant_id', $f['tenant'])->where('company_id', $f['company'])->where('action', 'like', 'module.record.%')->count(),
|
||||
'history_pairs_per_nonpending_record' => 4, 'statistics' => 'ANALYZE within disposable regression database; no index or schema changes', 'cases' => []];
|
||||
$cases = [
|
||||
['clients', 'all', 25, 1, ''], ['clients', 'all', 25, (int) ceil($size / 25), ''],
|
||||
['clients', 'all', 100, 1, ''], ['clients', 'pending', 25, 1, ''],
|
||||
['clients', 'associated', 25, 1, ''], ['clients', 'removed', 25, 1, ''],
|
||||
['clients', 'all', 25, 1, 'LOAD-client-000017'],
|
||||
['service_drafts', 'all', 25, 1, ''], ['billing_drafts', 'all', 25, 1, ''],
|
||||
];
|
||||
foreach ($cases as [$area, $state, $perPage, $page, $search]) {
|
||||
$expected = array_values(array_filter($fixture['ids'][$area], fn ($id) => match ($state) {
|
||||
'pending' => $id % 3 === 0, 'associated' => $id % 3 === 1, 'removed' => $id % 3 === 2, default => true,
|
||||
}));
|
||||
if ($search !== '') $expected = [$fixture['ids']['clients'][16]];
|
||||
$case = $this->measure(compact('area', 'state', 'perPage', 'page', 'search'), $expected);
|
||||
$dataset['cases'][] = $case;
|
||||
fwrite(STDOUT, "\nLOAD ".$size.' '.$case['label'].' '.$case['status'].' '.($case['median_ms'] ?? '')."ms\n");
|
||||
}
|
||||
// A complete traversal with page size 100 validates ID coverage,
|
||||
// not timing/capacity; it is capped to the smallest dataset.
|
||||
if ($size === 1000) {
|
||||
$seen = [];
|
||||
for ($p = 1; $p <= 10; $p++) {
|
||||
$rows = $this->getJson('/api/module-records?per_page=100&page='.$p)->assertOk()->json('records.clients');
|
||||
$seen = [...$seen, ...array_column($rows, 'id')];
|
||||
}
|
||||
$this->assertSame($fixture['ids']['clients'], $seen);
|
||||
$dataset['complete_traversal'] = ['pages' => 10, 'records' => count($seen), 'ordered_exact_coverage' => true];
|
||||
}
|
||||
$this->report['datasets'][] = $dataset;
|
||||
} finally { $this->measurement = null; DB::rollBack(); }
|
||||
}
|
||||
$this->report['completed'] = true;
|
||||
} finally {
|
||||
$this->measurement = null;
|
||||
$output = getenv('SGF_ASSOCIATION_LOAD_OUTPUT');
|
||||
if (!$output || realpath(dirname($output)) !== realpath(base_path('..'))) throw new \RuntimeException('Unexpected report directory');
|
||||
file_put_contents($output, json_encode($this->report, JSON_PRETTY_PRINT | JSON_THROW_ON_ERROR).PHP_EOL);
|
||||
}
|
||||
}
|
||||
|
||||
protected function fixture(int $size, bool $uniform = false): array
|
||||
{
|
||||
DB::statement("SET LOCAL statement_timeout = '30s'");
|
||||
$f = IdentityFixture::create('-load-'.bin2hex(random_bytes(6)));
|
||||
foreach (['clients', 'services', 'service_drafts', 'billing_drafts'] as $area) {
|
||||
IdentityFixture::grant($f['manager']->id, $f['tenant'], $f['company'], "$area.view", str_ends_with($area, '_drafts') ? $f['unit'] : null);
|
||||
}
|
||||
$ids = [];
|
||||
foreach (['clients' => $size, 'services' => $uniform ? $size : 100] as $area => $n) {
|
||||
DB::insert("INSERT INTO a_{$area}(tenant_id,company_id,unit_id,name,version,data,created_at,updated_at)
|
||||
SELECT ?,?,NULL,? || lpad(i::text,6,'0'),1,'{}'::jsonb,now(),now() FROM generate_series(1,?) i", [$f['tenant'], $f['company'], $area === 'clients' ? 'LOAD-client-' : 'LOAD-service-', $n]);
|
||||
$ids[$area] = DB::table('a_'.$area)->where('tenant_id', $f['tenant'])->where('company_id', $f['company'])->orderBy('id')->pluck('id')->all();
|
||||
}
|
||||
$draftCount = $uniform ? $size : min(1000, (int) ($size / 10));
|
||||
$draftRows = [];
|
||||
for ($i = 0; $i < $draftCount; $i++) {
|
||||
$draftRows[] = ['tenant_id' => $f['tenant'], 'company_id' => $f['company'], 'unit_id' => $f['unit'], 'name' => 'LOAD-draft-'.$i, 'version' => 1,
|
||||
'client_id' => $ids['clients'][$i], 'data' => json_encode(['currency' => 'MZN', 'tax_status' => 'pending', 'accounting_status' => 'not_processed',
|
||||
'lines' => [['service_id' => $ids['services'][$i % count($ids['services'])], 'quantity' => '1', 'unit_price' => '10']]]), 'created_at' => now(), 'updated_at' => now()];
|
||||
}
|
||||
foreach (array_chunk($draftRows, 500) as $chunk) DB::table('a_service_drafts')->insert($chunk);
|
||||
$ids['service_drafts'] = DB::table('a_service_drafts')->where('tenant_id', $f['tenant'])->where('company_id', $f['company'])->orderBy('id')->pluck('id')->all();
|
||||
foreach ($draftRows as $i => &$row) { $row['source_id'] = $ids['service_drafts'][$i]; $data = json_decode($row['data'], true); $data['source_version'] = 1; $row['data'] = json_encode($data); }
|
||||
unset($row);
|
||||
foreach (array_chunk($draftRows, 500) as $chunk) DB::table('a_billing_drafts')->insert($chunk);
|
||||
$ids['billing_drafts'] = DB::table('a_billing_drafts')->where('tenant_id', $f['tenant'])->where('company_id', $f['company'])->orderBy('id')->pluck('id')->all();
|
||||
foreach (array_keys($ids) as $area) {
|
||||
DB::insert("INSERT INTO identity_audit(action,actor_id,tenant_id,company_id,unit_id,details,created_at)
|
||||
SELECT CASE WHEN seq%2=1 THEN 'module.record.associated' ELSE 'module.record.disassociated' END,?,?,?,r.unit_id,
|
||||
jsonb_build_object('area',?::text,'record_id',r.id,'module_id','commercial','version',1,'reason','Synthetic load history'),now()
|
||||
FROM a_{$area} r CROSS JOIN generate_series(1,8) seq WHERE r.tenant_id=? AND r.company_id=? AND r.id%3<>0 ORDER BY seq,r.id",
|
||||
[$f['manager']->id, $f['tenant'], $f['company'], $area, $f['tenant'], $f['company']]);
|
||||
DB::insert("INSERT INTO identity_audit(action,actor_id,tenant_id,company_id,unit_id,details,created_at)
|
||||
SELECT 'module.record.associated',?,?,?,r.unit_id,jsonb_build_object('area',?::text,'record_id',r.id,'module_id','commercial','version',1,'reason','Synthetic active state'),now()
|
||||
FROM a_{$area} r WHERE r.tenant_id=? AND r.company_id=? AND r.id%3=1 ORDER BY r.id",
|
||||
[$f['manager']->id, $f['tenant'], $f['company'], $area, $f['tenant'], $f['company']]);
|
||||
}
|
||||
// Noise belongs to another tenant or an unselected unit, never copied from a client.
|
||||
DB::insert("INSERT INTO a_clients(tenant_id,company_id,unit_id,name,version,data,created_at,updated_at) SELECT ?,?,NULL,'LOAD-foreign-'||i,1,'{}',now(),now() FROM generate_series(1,100) i", [$f['otherTenant'], $f['foreign']]);
|
||||
$hidden = $draftRows[0]; unset($hidden['source_id']); $hidden['unit_id'] = $f['hidden']; $hidden['name'] = 'LOAD-hidden';
|
||||
DB::table('a_service_drafts')->insert($hidden);
|
||||
foreach (['a_clients', 'a_services', 'a_service_drafts', 'a_billing_drafts', 'identity_audit'] as $table) DB::statement('ANALYZE '.$table);
|
||||
return ['identity' => $f, 'ids' => $ids];
|
||||
}
|
||||
|
||||
private function measure(array $spec, array $expected): array
|
||||
{
|
||||
['area' => $area, 'state' => $state, 'perPage' => $perPage, 'page' => $page, 'search' => $search] = $spec;
|
||||
$out = ['label' => "$area/$state/size$perPage/page$page".($search !== '' ? '/search' : ''), 'status' => 'ok', 'samples' => []];
|
||||
$plans = [];
|
||||
for ($i = 0; $i < 3; $i++) {
|
||||
DB::beginTransaction(); DB::statement("SET LOCAL statement_timeout = '3s'");
|
||||
$this->queries = []; $this->measurement = ['queries' => 0, 'database_ms' => 0.0];
|
||||
memory_reset_peak_usage(); $memory = memory_get_usage(true); $this->started = hrtime(true);
|
||||
try {
|
||||
$r = $this->getJson('/api/module-records?'.http_build_query(['area' => $area, 'state' => $state, 'per_page' => $perPage, 'page' => $page, 'search' => $search]));
|
||||
$elapsed = (hrtime(true) - $this->started) / 1e6;
|
||||
$metrics = $this->measurement; $this->measurement = null;
|
||||
$r->assertOk()->assertJsonPath('pagination.total', count($expected));
|
||||
$rows = $r->json('records.'.$area);
|
||||
$this->assertSame(array_slice($expected, ($page - 1) * $perPage, $perPage), array_column($rows, 'id'));
|
||||
foreach ($rows as $row) $this->assertFalse($row['dependencies_restricted']);
|
||||
$out['samples'][] = [...$metrics, 'elapsed_ms' => round($elapsed, 3), 'database_ms' => round($metrics['database_ms'], 3),
|
||||
'peak_php_bytes' => memory_get_peak_usage(true), 'transient_php_bytes' => max(0, memory_get_peak_usage(true) - $memory), 'response_bytes' => strlen($r->getContent()), 'rows' => count($rows)];
|
||||
$plans = $this->queries;
|
||||
} catch (\Illuminate\Database\QueryException $e) {
|
||||
$this->measurement = null; $out['status'] = 'sql_timeout_or_error'; $out['sqlstate'] = $e->errorInfo[0] ?? $e->getCode(); $out['elapsed_ms'] = round((hrtime(true) - $this->started) / 1e6, 3); break;
|
||||
} catch (\RuntimeException $e) {
|
||||
$this->measurement = null;
|
||||
if ($e->getMessage() !== 'CASE_BUDGET_REACHED') throw $e;
|
||||
$out['status'] = 'case_budget_reached'; $out['elapsed_ms'] = round((hrtime(true) - $this->started) / 1e6, 3); break;
|
||||
} finally { $this->measurement = null; DB::rollBack(); }
|
||||
}
|
||||
if (count($out['samples']) === 3) {
|
||||
$times = array_column($out['samples'], 'elapsed_ms'); sort($times);
|
||||
$out['median_ms'] = $times[1]; $out['min_ms'] = $times[0]; $out['max_ms'] = $times[2];
|
||||
}
|
||||
uasort($plans, fn ($a, $b) => $b['total_ms'] <=> $a['total_ms']);
|
||||
$out['top_query_plans'] = [];
|
||||
foreach (array_slice($plans, 0, 3, true) as $signature => $query) {
|
||||
DB::beginTransaction(); DB::statement("SET LOCAL statement_timeout = '3s'");
|
||||
try {
|
||||
$raw = DB::select('EXPLAIN (ANALYZE,BUFFERS,FORMAT JSON) '.$query['sql'], $query['bindings']);
|
||||
$plan = json_decode($raw[0]->{'QUERY PLAN'}, true, 512, JSON_THROW_ON_ERROR)[0];
|
||||
$nodes = []; $this->nodes($plan['Plan'], $nodes);
|
||||
$out['top_query_plans'][] = ['signature' => $signature, 'query_shape' => $query['sql'], 'calls_per_request' => $query['count'],
|
||||
'cumulative_ms' => round($query['total_ms'], 3), 'planning_ms' => $plan['Planning Time'], 'execution_ms' => $plan['Execution Time'], 'nodes' => $nodes];
|
||||
} catch (\Illuminate\Database\QueryException $e) { $out['top_query_plans'][] = ['signature' => $signature, 'status' => 'plan_timeout_or_error', 'sqlstate' => $e->errorInfo[0] ?? $e->getCode()];
|
||||
} finally { DB::rollBack(); }
|
||||
}
|
||||
return $out;
|
||||
}
|
||||
|
||||
private function nodes(array $plan, array &$nodes): void
|
||||
{
|
||||
$nodes[] = array_intersect_key($plan, array_flip(['Node Type', 'Relation Name', 'Index Name', 'Actual Rows', 'Actual Loops', 'Rows Removed by Filter', 'Shared Hit Blocks', 'Shared Read Blocks', 'Temp Read Blocks', 'Temp Written Blocks']));
|
||||
foreach ($plan['Plans'] ?? [] as $child) $this->nodes($child, $nodes);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Benchmarks;
|
||||
|
||||
use App\Models\User;
|
||||
use App\Modules\Identity\Services\ModuleAssociationReview;
|
||||
use App\Shared\Context\CompanyContext;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Tests\TestCase;
|
||||
|
||||
// Test subprocess only, outside the public root and normal test discovery.
|
||||
final class ModuleAssociationReadWorkerTest extends TestCase
|
||||
{
|
||||
public function test_isolated_worker(): void
|
||||
{
|
||||
$run = (string) getenv('SGF_ISOLATED_REGRESSION_RUN');
|
||||
$this->assertMatchesRegularExpression('/^[a-f0-9]{12}$/D', $run);
|
||||
$file = realpath((string) getenv('SGF_ASSOCIATION_WORKER_INPUT'));
|
||||
$private = realpath(base_path('../private'));
|
||||
if (!$file || !$private || !str_starts_with($file, $private.DIRECTORY_SEPARATOR)) throw new \RuntimeException('Unexpected private input');
|
||||
$p = json_decode(file_get_contents($file), true, 512, JSON_THROW_ON_ERROR);
|
||||
$identity = DB::selectOne("SELECT current_database() AS database,current_user AS role,inet_server_port() AS port,current_setting('cluster_name') AS cluster");
|
||||
$this->assertSame('sgf_tempo_test', $identity->database); $this->assertSame('sgf_test_runner', $identity->role);
|
||||
$this->assertSame('sgf_modular_regression_'.$run, $identity->cluster); $this->assertSame(55439, (int) $identity->port);
|
||||
DB::select("SELECT set_config('application_name',?,false)", [$p['application_name']]);
|
||||
$this->withoutExceptionHandling();
|
||||
$user = User::findOrFail($p['actor']);
|
||||
$token = $p['context'] ?? bin2hex(random_bytes(24));
|
||||
$this->actingAs($user)->withSession(['auth_version' => $user->auth_version, 'company_context' => [
|
||||
'tenant_id' => $p['tenant'], 'company_id' => $p['company'], 'unit_id' => $p['unit'], 'module_id' => null, 'token' => $token,
|
||||
]])->withHeader('X-SGF-Context', $token);
|
||||
$result = ['mode' => $p['mode'], 'requests' => []];
|
||||
if ($p['mode'] === 'read') {
|
||||
DB::beginTransaction(); DB::statement('SET TRANSACTION READ ONLY'); DB::statement("SET LOCAL statement_timeout = '3s'");
|
||||
try {
|
||||
$this->getJson($p['url'])->assertOk();
|
||||
$this->barrier($p);
|
||||
$metrics = null;
|
||||
DB::listen(function ($q) use (&$metrics) {
|
||||
if ($metrics === null) return;
|
||||
if (!preg_match('/^\s*(select|with)\b/i', $q->sql)) throw new \RuntimeException('READ_WORKER_WROTE_SQL');
|
||||
$metrics['queries']++; $metrics['database_ms'] += $q->time;
|
||||
});
|
||||
for ($i = 0; $i < $p['requests']; $i++) {
|
||||
gc_collect_cycles(); memory_reset_peak_usage(); $memory = memory_get_usage(false);
|
||||
$metrics = ['queries' => 0, 'database_ms' => 0.0]; $start = hrtime(true); $wall = microtime(true);
|
||||
try {
|
||||
$r = $this->getJson($p['url']);
|
||||
$elapsed = (hrtime(true) - $start) / 1e6; $finished = microtime(true); $stats = $metrics; $metrics = null;
|
||||
$peak = memory_get_peak_usage(false);
|
||||
$r->assertOk()->assertJsonPath('pagination.total', $p['total']);
|
||||
$this->assertSame($p['response_sha256'], hash('sha256', $r->getContent()));
|
||||
$result['requests'][] = ['status' => 'ok', 'elapsed_ms' => round($elapsed, 3), 'started_at' => $wall, 'finished_at' => $finished,
|
||||
'queries' => $stats['queries'], 'database_ms' => round($stats['database_ms'], 3), 'incremental_php_bytes' => max(0, $peak - $memory), 'peak_php_bytes' => $peak];
|
||||
} catch (\Illuminate\Database\QueryException $e) {
|
||||
$metrics = null; $result['requests'][] = ['status' => 'sql_timeout_or_error', 'sqlstate' => $e->errorInfo[0] ?? $e->getCode(), 'elapsed_ms' => round((hrtime(true) - $start) / 1e6, 3)]; break;
|
||||
}
|
||||
}
|
||||
} finally { DB::rollBack(); }
|
||||
} elseif (in_array($p['mode'], ['confirm', 'confirm_http', 'preview_http'], true)) {
|
||||
app(CompanyContext::class)->selectFor($p['actor'], $p['tenant'], $p['company'], $p['unit']);
|
||||
$timeout = (int) ($p['lock_timeout_ms'] ?? 1000);
|
||||
if ($timeout < 100 || $timeout > 10000) throw new \RuntimeException('Unexpected diagnostic lock timeout');
|
||||
DB::select("SELECT set_config('lock_timeout',?,false)", [$timeout.'ms']);
|
||||
DB::select("SELECT set_config('statement_timeout',?,false)", [isset($p['lock_timeout_ms']) ? '12s' : '5s']);
|
||||
$this->barrier($p); $start = hrtime(true);
|
||||
try {
|
||||
if ($p['mode'] !== 'confirm') {
|
||||
$this->withExceptionHandling();
|
||||
$response = $this->postJson($p['mode'] === 'preview_http' ? '/api/module-records/preview' : '/api/module-records',
|
||||
$p['mode'] === 'preview_http' ? $p['preview_input'] : ['review_token' => $p['review_token']]);
|
||||
$response->assertStatus(423)->assertExactJson(['code' => 'association_busy',
|
||||
'message' => 'A conferência está ocupada por outra operação. Aguarde e volte a confirmar; o estado será novamente validado.']);
|
||||
$this->assertSame('no-store, private', $response->headers->get('Cache-Control'));
|
||||
$result['confirmation'] = ['status' => 'http_busy', 'http_status' => $response->status(), 'code' => $response->json('code'),
|
||||
'body_keys' => array_keys($response->json()), 'elapsed_ms' => round((hrtime(true) - $start) / 1e6, 3)];
|
||||
} else {
|
||||
$r = Request::create('/api/module-records', 'POST', ['review_token' => $p['review_token']]); $r->headers->set('X-SGF-Context', $token);
|
||||
$result['confirmation'] = ['status' => ($p['expected_result'] ?? false) ? 'completed' : 'unexpected_success', 'result' => app(ModuleAssociationReview::class)->confirm($r), 'elapsed_ms' => round((hrtime(true) - $start) / 1e6, 3)];
|
||||
}
|
||||
} catch (\Symfony\Component\HttpKernel\Exception\HttpExceptionInterface $e) {
|
||||
$result['confirmation'] = ['status' => 'refused', 'http_status' => $e->getStatusCode(), 'elapsed_ms' => round((hrtime(true) - $start) / 1e6, 3)];
|
||||
} catch (\Illuminate\Database\QueryException $e) {
|
||||
$result['confirmation'] = ['status' => 'database_error', 'sqlstate' => $e->errorInfo[0] ?? $e->getCode(), 'elapsed_ms' => round((hrtime(true) - $start) / 1e6, 3)];
|
||||
}
|
||||
} else { throw new \RuntimeException('Unknown worker mode'); }
|
||||
$result['finished_at'] = microtime(true);
|
||||
file_put_contents($p['result'], json_encode($result, JSON_PRETTY_PRINT | JSON_THROW_ON_ERROR));
|
||||
}
|
||||
|
||||
private function barrier(array $p): void
|
||||
{
|
||||
file_put_contents($p['ready'], 'ready'); $deadline = microtime(true) + 45;
|
||||
while (!is_file($p['go'])) { if (microtime(true) > $deadline) throw new \RuntimeException('Worker barrier timed out'); usleep(5000); }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,118 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Benchmarks;
|
||||
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Tests\Support\IdentityFixture;
|
||||
|
||||
require_once __DIR__.'/ModuleAssociationConcurrencyTest.php';
|
||||
|
||||
final class ModuleAssociationReleaseTest extends ModuleAssociationConcurrencyTest
|
||||
{
|
||||
// New cases only; the inherited 440-read benchmark is not executed.
|
||||
public function test_synthetic_volume_and_query_plans(): void
|
||||
{
|
||||
$run = (string) getenv('SGF_ISOLATED_REGRESSION_RUN');
|
||||
$this->assertMatchesRegularExpression('/^[a-f0-9]{12}$/D', $run);
|
||||
$identity = DB::selectOne("SELECT current_database() AS database,current_user AS role,inet_server_port() AS port,current_setting('cluster_name') AS cluster");
|
||||
$this->assertSame('sgf_tempo_test', $identity->database); $this->assertSame('sgf_test_runner', $identity->role);
|
||||
$this->assertSame('sgf_modular_regression_'.$run, $identity->cluster); $this->assertSame(55439, (int) $identity->port);
|
||||
$tag = bin2hex(random_bytes(6)); $this->directory = base_path('../private/association-release-'.$tag); mkdir($this->directory, 0700, true);
|
||||
$before = $this->baseline(); $scope = IdentityFixture::create('-release-'.$tag);
|
||||
foreach (['clients.view', 'clients.edit'] as $permission) IdentityFixture::grant($scope['manager']->id, $scope['tenant'], $scope['company'], $permission);
|
||||
$cases = ['rollback_then_complete', 'commit_then_stale_revision', 'commit_then_same_token', 'commit_record_change_then_stale_version']; $ids = [];
|
||||
foreach ($cases as $name) $ids[$name] = DB::table('a_clients')->insertGetId(['tenant_id' => $scope['tenant'], 'company_id' => $scope['company'], 'unit_id' => null,
|
||||
'name' => 'Synthetic association release '.$name, 'version' => 1, 'data' => '{}', 'created_at' => now(), 'updated_at' => now()]);
|
||||
$this->actingAs($scope['manager'])->withSession(['auth_version' => $scope['manager']->auth_version]);
|
||||
$context = $this->postJson('/api/context', ['tenant_id' => $scope['tenant'], 'company_id' => $scope['company'], 'unit_id' => $scope['unit'], 'module_id' => null])->assertOk()->json('context.token');
|
||||
$this->withHeader('X-SGF-Context', $context);
|
||||
DB::commit();
|
||||
file_put_contents($this->directory.'/manifest.json', json_encode(['synthetic_only' => true, 'tenant' => $scope['tenant'], 'company' => $scope['company'], 'unit' => $scope['unit'], 'actor' => $scope['manager']->id, 'clients' => $ids], JSON_PRETTY_PRINT | JSON_THROW_ON_ERROR));
|
||||
$report = ['date' => '2026-11-02', 'run_tag' => $tag, 'identity' => $identity, 'source_sha256' => hash_file('sha256', base_path('app/Modules/Identity/Services/ModuleAssociationReview.php')),
|
||||
'fixture_policy' => 'Four new QA-only clients committed for worker visibility; fixture and synthetic results retained in regression DB with private manifest.', 'cases' => []];
|
||||
try {
|
||||
foreach ($cases as $number => $name) {
|
||||
$id = $ids[$name]; $input = ['area' => 'clients', 'id' => $id, 'version' => 1, 'module_id' => 'commercial', 'operation' => 'associate', 'reason' => 'Synthetic transaction release '.$name];
|
||||
$first = $this->postJson('/api/module-records/preview', $input)->assertOk()->assertJsonPath('before', [])->json('review_token');
|
||||
$second = $this->postJson('/api/module-records/preview', $input)->assertOk()->json('review_token');
|
||||
$token = $name === 'commit_then_same_token' ? $first : $second;
|
||||
$parent = (int) DB::selectOne('SELECT pg_backend_pid() AS pid')->pid;
|
||||
DB::beginTransaction(); $job = null; $released = false; $firstEvent = null;
|
||||
try {
|
||||
if ($name === 'commit_record_change_then_stale_version') {
|
||||
DB::table('companies')->where('tenant_id', $scope['tenant'])->where('id', $scope['company'])->lockForUpdate()->firstOrFail();
|
||||
DB::table('a_clients')->where('id', $id)->update(['version' => 2, 'name' => 'Synthetic version changed during wait']);
|
||||
} else {
|
||||
$firstEvent = $this->postJson('/api/module-records', ['review_token' => $first])->assertOk()->assertJsonPath('replayed', false)->json('event_id');
|
||||
}
|
||||
$application = 'sgf_assoc_release_'.$tag.'_'.$number; $gate = $this->directory.'/go-'.$number;
|
||||
$job = $this->launch(['mode' => 'confirm', 'expected_result' => true, 'lock_timeout_ms' => 8000,
|
||||
'actor' => $scope['manager']->id, 'tenant' => $scope['tenant'], 'company' => $scope['company'], 'unit' => $scope['unit'],
|
||||
'context' => $context, 'review_token' => $token, 'go' => $gate, 'application_name' => $application]);
|
||||
$this->ready([$job]); file_put_contents($gate, 'go');
|
||||
$wait = $this->waitForCompanyLock($application, $parent);
|
||||
usleep(200000);
|
||||
if ($name === 'rollback_then_complete') DB::rollBack(); else DB::commit();
|
||||
$released = true;
|
||||
$result = $this->collect($job)['confirmation'];
|
||||
$events = DB::table('identity_audit')->where('tenant_id', $scope['tenant'])->where('company_id', $scope['company'])
|
||||
->where('action', 'like', 'module.record.%')->whereRaw("details::jsonb->>'area' = 'clients'")->whereRaw("details::jsonb->>'record_id' = ?", [(string) $id])->count();
|
||||
$version = (int) DB::table('a_clients')->where('id', $id)->value('version');
|
||||
if (in_array($name, ['rollback_then_complete', 'commit_then_same_token'], true)) {
|
||||
$this->assertSame('completed', $result['status']); $this->assertTrue($result['result']['ok']);
|
||||
$this->assertSame($name === 'commit_then_same_token', $result['result']['replayed']);
|
||||
$this->assertSame(1, $events); $this->assertSame(1, $version);
|
||||
$replay = $this->postJson('/api/module-records', ['review_token' => $token])->assertOk()->assertJsonPath('replayed', true)->json('event_id');
|
||||
$this->assertSame((int) $result['result']['event_id'], (int) $replay);
|
||||
} else {
|
||||
$this->assertSame('refused', $result['status']); $this->assertSame(409, $result['http_status']);
|
||||
$this->postJson('/api/module-records', ['review_token' => $token])->assertConflict();
|
||||
$this->postJson('/api/module-records/preview', [...$input, 'version' => $version])->assertOk();
|
||||
$this->assertSame($name === 'commit_record_change_then_stale_version' ? 0 : 1, $events);
|
||||
$this->assertSame($name === 'commit_record_change_then_stale_version' ? 2 : 1, $version);
|
||||
if ($name === 'commit_then_stale_revision') $this->postJson('/api/module-records', ['review_token' => $first])->assertOk()->assertJsonPath('replayed', true);
|
||||
}
|
||||
$final = DB::table('identity_audit')->where('tenant_id', $scope['tenant'])->where('company_id', $scope['company'])->where('action', 'like', 'module.record.%')
|
||||
->whereRaw("details::jsonb->>'area' = 'clients'")->whereRaw("details::jsonb->>'record_id' = ?", [(string) $id])->count();
|
||||
$this->assertSame($events, $final, 'Retry must not append another decision');
|
||||
$report['cases'][] = ['case' => $name, 'release' => $name === 'rollback_then_complete' ? 'rollback' : 'commit', 'wait' => $wait,
|
||||
'second_status' => $result['status'], 'second_http_status' => $result['http_status'] ?? 200, 'second_replayed' => $result['result']['replayed'] ?? null,
|
||||
'elapsed_ms' => $result['elapsed_ms'], 'record_version_after' => $version, 'decision_events_after_retry' => $final,
|
||||
'first_event_persisted' => $firstEvent === null ? null : DB::table('identity_audit')->where('id', $firstEvent)->exists(), 'duplicate_decision' => false];
|
||||
fwrite(STDOUT, "\nRELEASE $name status=".($result['http_status'] ?? 200)." events=$final version=$version\n");
|
||||
} finally {
|
||||
if (!$released && $job !== null && is_resource($job['process'])) { proc_terminate($job['process']); proc_close($job['process']); }
|
||||
while (DB::transactionLevel() > 0) DB::rollBack();
|
||||
}
|
||||
}
|
||||
$report['completed'] = true;
|
||||
} finally {
|
||||
foreach ($this->jobs as $job) if (is_resource($job['process'])) { proc_terminate($job['process']); proc_close($job['process']); }
|
||||
while (DB::transactionLevel() > 0) DB::rollBack();
|
||||
$report['previous_qa_rows_and_index_catalog_preserved'] = $this->baseline($before) === $before;
|
||||
$this->assertTrue($report['previous_qa_rows_and_index_catalog_preserved']);
|
||||
$output = getenv('SGF_ASSOCIATION_LOAD_OUTPUT');
|
||||
if (!$output || realpath(dirname($output)) !== realpath(base_path('..'))) throw new \RuntimeException('Unexpected report directory');
|
||||
file_put_contents($output, json_encode($report, JSON_PRETTY_PRINT | JSON_THROW_ON_ERROR).PHP_EOL);
|
||||
DB::beginTransaction();
|
||||
}
|
||||
}
|
||||
|
||||
private function waitForCompanyLock(string $application, int $parent): array
|
||||
{
|
||||
$deadline = microtime(true) + 5;
|
||||
do {
|
||||
DB::select('SELECT pg_stat_clear_snapshot()');
|
||||
$row = DB::selectOne("SELECT pid,wait_event_type,wait_event,to_json(pg_blocking_pids(pid)) AS blockers,query FROM pg_stat_activity WHERE application_name=? AND wait_event_type='Lock'", [$application]);
|
||||
if ($row) {
|
||||
$this->assertContains($parent, json_decode($row->blockers, true));
|
||||
$this->assertStringContainsString('"companies"', $row->query); $this->assertMatchesRegularExpression('/for update/i', $row->query);
|
||||
$locks = DB::select('SELECT locktype,mode FROM pg_locks WHERE pid=? AND NOT granted', [$row->pid]);
|
||||
return ['actual_wait_observed' => true, 'blocking_parent_verified' => true, 'statement_table' => 'companies', 'statement_for_update' => true,
|
||||
'wait_event_type' => $row->wait_event_type, 'wait_event' => $row->wait_event, 'ungranted_locks' => $locks];
|
||||
}
|
||||
usleep(25000);
|
||||
} while (microtime(true) < $deadline);
|
||||
$this->fail('The second confirmation did not wait on the expected company lock');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
import fs from 'node:fs';
|
||||
import crypto from 'node:crypto';
|
||||
|
||||
// Reads existing sanitised proofs only. No application, runtime or DB connection.
|
||||
const read=p=>JSON.parse(fs.readFileSync(p,'utf8').replace(/^\uFEFF/,''));
|
||||
const sha=p=>crypto.createHash('sha256').update(fs.readFileSync(p)).digest('hex');
|
||||
const prefix='docs/proofs/module-association-';
|
||||
const http=read(prefix+'http-lock-summary-20261102.json');
|
||||
const checked=[http.proof,...http.previous_proofs];
|
||||
for(const p of checked)if(sha(p.path)!==p.sha256)throw Error('Prior evidence changed: '+p.path);
|
||||
if(sha('app/Modules/Identity/Services/ModuleAssociationReview.php')!==http.source_sha256
|
||||
||sha('app/Modules/Identity/Http/ModuleRecordsController.php')!==http.controller_sha256)throw Error('Current source differs from HTTP evidence');
|
||||
const a=read(prefix+'optimization-comparison-20261102.json');
|
||||
const projection=read(prefix+'projection-summary-20261102.json');
|
||||
const concurrent=read(prefix+'concurrency-summary-20261102.json');
|
||||
const release=read(prefix+'release-summary-20261102.json');
|
||||
if(projection.source.final_sha256!==http.source_sha256||concurrent.source_sha256!==http.source_sha256||release.source_sha256!==http.source_sha256)throw Error('Source provenance mismatch');
|
||||
const variants=['baseline','batch','indexed_batch'];
|
||||
const coverage=[],missing=[];let complete=0,limited=0;
|
||||
for(const volume of [1000,5000,10000]){
|
||||
const d=a.datasets.find(d=>d.records_per_area===volume);
|
||||
for(const area of ['clients','services','service_drafts','billing_drafts'])for(const page of [25,100]){
|
||||
const matches=d?.cases.filter(c=>c.area===area&&c.state==='all'&&c.page_size===page)||[];
|
||||
if(matches.length!==1){missing.push({volume,area,page});continue;}
|
||||
const c=matches[0];
|
||||
for(const v of variants){
|
||||
const samples=c.variants[v];
|
||||
if(!samples?.length||!c.summary[v])missing.push({volume,area,page,variant:v});
|
||||
for(const s of samples||[])if(!['ok','case_budget_reached'].includes(s.status))throw Error('Unexpected measurement outcome');
|
||||
if(!c.summary[v].censored&&samples.length!==3)throw Error('Incomplete uncensored case');
|
||||
}
|
||||
if(!c.successful_responses_identical)throw Error('Response equivalence missing');
|
||||
coverage.push({records_per_area:volume,area,page_size:page,before:c.summary.baseline,after_joint_read:c.summary.batch,after_joint_read_with_indexes:c.summary.indexed_batch});
|
||||
}
|
||||
}
|
||||
for(const d of a.datasets)for(const c of d.cases)for(const v of variants)for(const s of c.variants[v]){
|
||||
if(s.status==='ok')complete++;else if(s.status==='case_budget_reached')limited++;else throw Error('Unclassified sample');
|
||||
}
|
||||
if(missing.length||coverage.length!==24||complete!==258||limited!==13)throw Error('Coverage gap: '+JSON.stringify({missing,complete,limited}));
|
||||
const summary={date:'2026-11-02',joint_page_read_implemented:true,source_sha256:http.source_sha256,controller_sha256:http.controller_sha256,
|
||||
method:{new_database_connections:0,new_measurements:0,valid_proofs_reused:true,reason:'All 24 required area/volume/page combinations have measured A/B/C outcomes, including explicitly bounded baseline attempts; no dimension is untested.',
|
||||
historical_batch_before_projection_sha256:a.optimized_sha256,current_projection_all_three_volume_matrix_measured:false,
|
||||
current_projection_scope:'Separate 10000-record-per-area, 100-line profiles; do not present historical B as current projected-code measurements.',memory_unit:'bytes',memory_metric:'incremental_php_used_bytes; peak_php_used_bytes also retained',latency_metric:'median_ms only for complete samples'},
|
||||
coverage,missing_combinations:missing,additional_filter_cases:9,complete_measured_requests:complete,bounded_baseline_attempts:limited,
|
||||
current_projection:projection.experiment,concurrency:{test:concurrent.test,matrix_reads:concurrent.matrix_complete_requests,mixed_reads:concurrent.mixed_read_complete_requests},
|
||||
release:release.test,http:http.tests,validated_existing_proofs:checked,
|
||||
preservation:{existing_proofs_unchanged:true,protected_six_records_untouched:true,blocked_cache_untouched:true,destination_connected:false,application_code_changed:false,
|
||||
indexes_created:false,rights_changed:false,purchasing_activated:false,publication:false,checkpoint:false},visual_review_pending:true,deployment_capacity_validated:false};
|
||||
fs.writeFileSync(prefix+'consolidation-summary-20261102.json',JSON.stringify(summary,null,2)+'\n');
|
||||
console.log(JSON.stringify({required_combinations:coverage.length,missing:missing.length,successful_requests:complete,bounded_attempts:limited,new_measurements:0,prior_hashes_verified:checked.length,current_source_verified:true},null,2));
|
||||
@@ -0,0 +1,46 @@
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import crypto from 'node:crypto';
|
||||
import {fileURLToPath} from 'node:url';
|
||||
|
||||
// Preparation inventory only: never loads Laravel, executes source or connects to DBs.
|
||||
const root=path.resolve(path.dirname(fileURLToPath(import.meta.url)),'..');
|
||||
const roots={sgf:root,compras:path.resolve(root,'../compras')};
|
||||
const planPath=path.join(root,'docs/integration/sgf-flow-plan.json');
|
||||
const digest=b=>crypto.createHash('sha256').update(b).digest('hex');
|
||||
const planBytes=fs.readFileSync(planPath), plan=JSON.parse(planBytes);
|
||||
const requireValue=(condition,message)=>{if(!condition)throw Error(message);};
|
||||
requireValue(plan.schema_version===1&&plan.status==='preparation_only','Unsupported plan');
|
||||
const modules=new Set(plan.modules), sourceIds=new Set(), stages=new Map();
|
||||
requireValue(modules.size===6,'Expected the six existing modules');
|
||||
const sources=plan.sources.map(s=>{
|
||||
requireValue(!sourceIds.has(s.id),'Duplicate source');sourceIds.add(s.id);
|
||||
requireValue(roots[s.repository]&&/^(app|packages|resources|docs)\//.test(s.path)&&/\.(php|js|md)$/.test(s.path),'Unexpected source path');
|
||||
const base=fs.realpathSync(roots[s.repository]), file=fs.realpathSync(path.resolve(base,s.path));
|
||||
requireValue(file.startsWith(base+path.sep)&&!s.path.split('/').includes('..'),'Source escapes repository');
|
||||
return {...s,sha256:digest(fs.readFileSync(file)),meaning:'source_present_only_not_operational_proof'};
|
||||
});
|
||||
for(const s of plan.stages){
|
||||
requireValue(!stages.has(s.id)&&modules.has(s.owner),'Duplicate stage or unknown owner');
|
||||
requireValue(['planned','source_not_integrated','partial_synthetic'].includes(s.state),'Preparation cannot claim operational delivery');
|
||||
requireValue(s.effect&&s.gap&&s.exit_evidence.length&&s.source_ids.every(id=>sourceIds.has(id)),'Stage missing evidence/gap/source');
|
||||
stages.set(s.id,s);
|
||||
}
|
||||
const visited=new Set(), visiting=new Set();
|
||||
function visit(id){
|
||||
requireValue(stages.has(id),'Unknown dependency');
|
||||
requireValue(!visiting.has(id),'Cyclic mandatory dependency');
|
||||
if(visited.has(id))return;
|
||||
visiting.add(id);for(const parent of stages.get(id).requires)visit(parent);visiting.delete(id);visited.add(id);
|
||||
}
|
||||
for(const id of stages.keys())visit(id);
|
||||
for(const c of plan.event_consumers)requireValue(stages.has(c.source)&&stages.has(c.target)&&c.status==='planned'&&c.rule,'Invalid planned consumer');
|
||||
requireValue(plan.evidence_contract.runtime_implemented===false&&plan.evidence_contract.unknown_is_zero===false,'Evidence contract must remain a proposal');
|
||||
const report={id:plan.id,date:plan.date,kind:'static_preparation_inventory',plan_sha256:digest(planBytes),sources,
|
||||
stages:plan.stages.map(s=>({id:s.id,owner:s.owner,effect:s.effect,state:s.state,operational_integration_validated:false,gap:s.gap})),
|
||||
checks:{source_paths_and_hashes:true,single_owner_per_stage:true,mandatory_dependency_graph_acyclic:true,planned_consumers_explicit:true},
|
||||
limitations:['Source existence is not execution, parity, acceptance or deployment evidence.','Dependencies represent the selected synthetic path, not universal product eligibility rules.','No stage transition engine, inventory adapter or journal engine implemented by this inventory.'],
|
||||
preservation:{database_connections:0,source_execution:false,application_routes_changed:false,rights_changed:false,protected_six_records_untouched:true,blocked_cache_untouched:true,
|
||||
demos_changed:false,purchasing_activated:false,publication:false,checkpoint:false},visual_review_pending:true,deployment_capacity_validated:false};
|
||||
fs.writeFileSync(path.join(root,'docs/proofs/module-flow-preparation-20261102.json'),JSON.stringify(report,null,2)+'\n');
|
||||
console.log(JSON.stringify({modules:modules.size,stages:stages.size,sources:sources.length,mandatory_cycles:0,kind:report.kind,database_connections:0,operational_integration_validated:false},null,2));
|
||||
@@ -0,0 +1,31 @@
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import crypto from 'node:crypto';
|
||||
import {spawnSync} from 'node:child_process';
|
||||
const root='C:/laragon/data/sgf-tempo/run';
|
||||
if(process.argv[2] && !['plans','access','fanout','concurrency','release','http-lock'].includes(process.argv[2]))throw Error('Use no argument, plans, access, fanout, concurrency, release or http-lock');
|
||||
const plans=process.argv[2]==='plans';
|
||||
const access=process.argv[2]==='access';
|
||||
const fanout=process.argv[2]==='fanout';
|
||||
const concurrency=process.argv[2]==='concurrency';
|
||||
const release=process.argv[2]==='release';
|
||||
const httpLock=process.argv[2]==='http-lock';
|
||||
const state=JSON.parse(fs.readFileSync(path.join(root,'modular-regression-current.json'),'utf8'));
|
||||
if(!/^[a-f0-9]{12}$/.test(state.id)||path.resolve(state.root)!==path.resolve(root,'modular-regression-'+state.id)||path.resolve(state.app)!==path.resolve(state.root,'app')||state.port!==55439)throw Error('Unexpected isolated host');
|
||||
if(fanout){
|
||||
const before=fs.readFileSync(path.join(state.root,'pre-projection-review.php'),'utf8');
|
||||
const expected=fs.readFileSync(path.join(state.root,'pre-projection-sha256.txt'),'utf8').trim();
|
||||
const recovered=before.replace('final class PreProjectionModuleAssociationReview','final class ModuleAssociationReview');
|
||||
if(!before.includes('final class PreProjectionModuleAssociationReview')||crypto.createHash('sha256').update(recovered).digest('hex')!==expected)throw Error('Pre-projection baseline integrity mismatch');
|
||||
}
|
||||
const old=spawnSync('git',['show','340f3bb:app/Modules/Identity/Services/ModuleAssociationReview.php'],{encoding:'utf8',windowsHide:true});
|
||||
if(old.status!==0||!old.stdout.includes('final class ModuleAssociationReview'))throw Error('Cannot recover the accepted baseline');
|
||||
const baselineSha=crypto.createHash('sha256').update(old.stdout).digest('hex');
|
||||
const optimizedSha=crypto.createHash('sha256').update(fs.readFileSync(path.join(state.app,'app/Modules/Identity/Services/ModuleAssociationReview.php'))).digest('hex');
|
||||
fs.writeFileSync(path.join(state.root,'baseline-review.php'),old.stdout.replace('final class ModuleAssociationReview','final class BaselineModuleAssociationReview'));
|
||||
const output=path.join(state.root,(plans?'association-index-plans-':access?'association-access-parity-':fanout?'association-fanout-':concurrency?'association-concurrency-':release?'association-release-':httpLock?'association-http-lock-':'association-compare-')+Date.now()+'.json');
|
||||
const test=plans?'ModuleAssociationIndexPlanTest.php':access?'ModuleAssociationAccessParityTest.php':fanout?'ModuleAssociationFanoutTest.php':concurrency?'ModuleAssociationConcurrencyTest.php':release?'ModuleAssociationReleaseTest.php':httpLock?'ModuleAssociationHttpLockTest.php':'ModuleAssociationCompareTest.php';
|
||||
const result=spawnSync('C:/laragon/tools/sgf-tempo/php-8.4.27/php.exe',['vendor/phpunit/phpunit/phpunit','scripts/benchmarks/'+test,'--log-junit',output.replace('.json','.xml')],{
|
||||
cwd:state.app,stdio:'inherit',windowsHide:true,env:{...process.env,SGF_ISOLATED_REGRESSION_RUN:state.id,SGF_ASSOCIATION_LOAD_OUTPUT:output,SGF_ASSOCIATION_BASELINE_SHA:baselineSha,SGF_ASSOCIATION_OPTIMIZED_SHA:optimizedSha},
|
||||
});
|
||||
console.log(JSON.stringify({exit_code:result.status,report:output,baseline_sha256:baselineSha}));process.exitCode=result.status??1;
|
||||
@@ -0,0 +1,25 @@
|
||||
<?php
|
||||
|
||||
// Read-only fingerprints of the disposable regression DB, never of client data.
|
||||
putenv('APP_ENV=testing');
|
||||
$runtime = 'C:/laragon/data/sgf-tempo/run';
|
||||
$state = json_decode(file_get_contents($runtime.'/modular-regression-current.json'), true, 512, JSON_THROW_ON_ERROR);
|
||||
if (!preg_match('/^[a-f0-9]{12}$/D', $state['id']) || realpath($state['root']) !== realpath($runtime.'/modular-regression-'.$state['id'])
|
||||
|| realpath($state['app']) !== realpath($state['root'].'/app') || $state['port'] !== 55439) throw new RuntimeException('Unexpected regression host');
|
||||
require $state['app'].'/vendor/autoload.php';
|
||||
$app = require $state['app'].'/bootstrap/app.php';
|
||||
$app->make(Illuminate\Contracts\Console\Kernel::class)->bootstrap();
|
||||
App\Shared\Testing\TestingDatabaseGuard::assertSafe();
|
||||
$db = Illuminate\Support\Facades\DB::connection();
|
||||
$db->beginTransaction();
|
||||
try {
|
||||
$db->statement('SET TRANSACTION ISOLATION LEVEL REPEATABLE READ, READ ONLY');
|
||||
$identity = $db->selectOne("SELECT current_database() AS database,current_user AS role,inet_server_port() AS port,current_setting('cluster_name') AS cluster");
|
||||
if ($identity->database !== 'sgf_tempo_test' || $identity->role !== 'sgf_test_runner' || (int) $identity->port !== 55439
|
||||
|| $identity->cluster !== 'sgf_modular_regression_'.$state['id']) throw new RuntimeException('Unexpected effective regression identity');
|
||||
$tables = [];
|
||||
foreach (['a_clients', 'a_services', 'a_service_drafts', 'a_billing_drafts', 'identity_audit', 'users', 'permission_grants', 'company_memberships'] as $table) {
|
||||
$tables[$table] = $db->selectOne("SELECT count(*) AS rows,md5(coalesce(string_agg(row_to_json(t)::text,E'\\n' ORDER BY row_to_json(t)::text),'')) AS fingerprint FROM {$table} t");
|
||||
}
|
||||
echo json_encode(['identity' => $identity, 'tables' => $tables, 'read_only' => true], JSON_PRETTY_PRINT | JSON_THROW_ON_ERROR).PHP_EOL;
|
||||
} finally { $db->rollBack(); }
|
||||
@@ -0,0 +1,15 @@
|
||||
// Runs only the new load evaluation in the existing isolated regression copy.
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import {spawnSync} from 'node:child_process';
|
||||
const runtime='C:/laragon/data/sgf-tempo/run';
|
||||
const state=JSON.parse(fs.readFileSync(path.join(runtime,'modular-regression-current.json'),'utf8'));
|
||||
if(!/^[a-f0-9]{12}$/.test(state.id)||path.resolve(state.root)!==path.resolve(runtime,'modular-regression-'+state.id)
|
||||
||path.resolve(state.app)!==path.resolve(state.root,'app')||state.port!==55439)throw Error('Unexpected isolated host');
|
||||
const php='C:/laragon/tools/sgf-tempo/php-8.4.27/php.exe';
|
||||
const output=path.join(state.root,'association-load-'+Date.now()+'.json');
|
||||
const result=spawnSync(php,['vendor/phpunit/phpunit/phpunit','scripts/benchmarks/ModuleAssociationLoadTest.php','--log-junit',output.replace('.json','.xml')],{
|
||||
cwd:state.app,stdio:'inherit',windowsHide:true,env:{...process.env,SGF_ISOLATED_REGRESSION_RUN:state.id,SGF_ASSOCIATION_LOAD_OUTPUT:output},
|
||||
});
|
||||
console.log(JSON.stringify({exit_code:result.status,report:output,source_guard_unchanged:true}));
|
||||
process.exitCode=result.status??1;
|
||||
@@ -0,0 +1,81 @@
|
||||
<?php
|
||||
|
||||
// Administrative CLI inventory only. Never infer or write module associations.
|
||||
$targets = [
|
||||
'installed' => [dirname(__DIR__), 'sgf_tempo_dev', 'sgf_dev_app', 55432],
|
||||
'demo' => ['C:/laragon/data/sgf-tempo/run/modular-regression-2576427d1939/development-app', 'sgf_modules_dev_2576427d1939', 'sgf_modules_qa_2576427d1939', 55439],
|
||||
];
|
||||
$target = $argv[1] ?? '';
|
||||
if (count($argv) !== 2 || !isset($targets[$target])) {
|
||||
fwrite(STDERR, "Usage: php scripts/module-associations-inventory.php installed|demo\n");
|
||||
exit(1);
|
||||
}
|
||||
[$root, $database, $role, $port] = $targets[$target];
|
||||
require $root.'/vendor/autoload.php';
|
||||
$app = require $root.'/bootstrap/app.php';
|
||||
$app->make(Illuminate\Contracts\Console\Kernel::class)->bootstrap();
|
||||
$db = Illuminate\Support\Facades\DB::connection();
|
||||
$db->beginTransaction();
|
||||
try {
|
||||
$db->statement('SET TRANSACTION ISOLATION LEVEL REPEATABLE READ, READ ONLY');
|
||||
$db->statement("SET LOCAL statement_timeout = '15s'");
|
||||
$identity = $db->selectOne('SELECT current_database() AS database,current_user AS role,inet_server_port() AS port,current_setting(\'transaction_read_only\') AS read_only');
|
||||
if ($identity->database !== $database || $identity->role !== $role || (int) $identity->port !== $port || $identity->read_only !== 'on') {
|
||||
throw new RuntimeException('Unexpected database identity or writable transaction.');
|
||||
}
|
||||
$areas = [];
|
||||
foreach (['clients', 'services', 'service_drafts', 'billing_drafts'] as $area) {
|
||||
// Same effective-state rule as the application: latest event per
|
||||
// tenant/company/area/record/module; a removal never restores a default.
|
||||
$cte = "WITH latest AS (
|
||||
SELECT DISTINCT ON (tenant_id,company_id,details::jsonb->>'record_id',details::jsonb->>'module_id')
|
||||
tenant_id,company_id,details::jsonb->>'record_id' AS record_id,action
|
||||
FROM identity_audit
|
||||
WHERE action IN ('module.record.associated','module.record.disassociated')
|
||||
AND details::jsonb->>'area' = ?
|
||||
ORDER BY tenant_id,company_id,details::jsonb->>'record_id',details::jsonb->>'module_id',id DESC
|
||||
), inventory AS (
|
||||
SELECT r.id,r.tenant_id,r.company_id,r.unit_id,r.version,
|
||||
count(l.record_id) AS reviewed_links,
|
||||
count(l.record_id) FILTER (WHERE l.action='module.record.associated') AS active_links
|
||||
FROM a_{$area} r LEFT JOIN latest l
|
||||
ON l.tenant_id=r.tenant_id AND l.company_id=r.company_id AND l.record_id=r.id::text
|
||||
GROUP BY r.id,r.tenant_id,r.company_id,r.unit_id,r.version
|
||||
) ";
|
||||
$scopes = $db->select($cte."SELECT tenant_id,company_id,unit_id,count(*) AS total,
|
||||
count(*) FILTER (WHERE active_links>0) AS associated,
|
||||
count(*) FILTER (WHERE reviewed_links=0) AS never_associated,
|
||||
count(*) FILTER (WHERE reviewed_links>0 AND active_links=0) AS all_links_removed
|
||||
FROM inventory GROUP BY tenant_id,company_id,unit_id ORDER BY tenant_id,company_id,unit_id", [$area]);
|
||||
$sample = $db->select($cte."SELECT id,tenant_id,company_id,unit_id,version,
|
||||
CASE WHEN reviewed_links=0 THEN 'never_associated' ELSE 'all_links_removed' END AS reason
|
||||
FROM inventory WHERE active_links=0 ORDER BY tenant_id,company_id,unit_id,id LIMIT 100", [$area]);
|
||||
if (in_array($area, ['service_drafts', 'billing_drafts'], true)) {
|
||||
foreach ($sample as $row) {
|
||||
$columns = ['client_id', 'data'];
|
||||
if ($area === 'billing_drafts') {
|
||||
$columns[] = 'source_id';
|
||||
}
|
||||
$record = $db->table('a_'.$area)->where('tenant_id', $row->tenant_id)
|
||||
->where('company_id', $row->company_id)->where('unit_id', $row->unit_id)
|
||||
->where('id', $row->id)->first($columns);
|
||||
$data = json_decode($record->data, true, 512, JSON_THROW_ON_ERROR);
|
||||
$row->references = ['client_id' => (int) $record->client_id,
|
||||
'service_ids' => array_values(array_unique(array_column($data['lines'] ?? [], 'service_id')))];
|
||||
if ($area === 'billing_drafts') {
|
||||
$row->references['source_id'] = (int) $record->source_id;
|
||||
$row->references['source_version'] = $data['source_version'] ?? null;
|
||||
}
|
||||
}
|
||||
}
|
||||
$pending = array_sum(array_map(fn ($s) => (int) $s->never_associated + (int) $s->all_links_removed, $scopes));
|
||||
$areas[$area] = ['scopes' => $scopes, 'pending_total' => $pending, 'pending_sample' => $sample, 'sample_truncated' => $pending > count($sample)];
|
||||
}
|
||||
$report = ['captured_at' => gmdate('c'), 'target' => $target, 'identity' => $identity,
|
||||
'method' => 'Repeatable-read read-only transaction; latest explicit event per module; no business names or document contents exported.',
|
||||
'classification_limit' => 'Never associated is a current state, not evidence of age or an inferred destination. Removed links require separate review.',
|
||||
'areas' => $areas, 'writes_performed' => false];
|
||||
} finally {
|
||||
$db->rollBack();
|
||||
}
|
||||
echo json_encode($report, JSON_PRETTY_PRINT | JSON_THROW_ON_ERROR).PHP_EOL;
|
||||
@@ -0,0 +1,7 @@
|
||||
import fs from 'node:fs';import path from 'node:path';import {spawnSync} from 'node:child_process';
|
||||
const s=JSON.parse(fs.readFileSync('C:/laragon/data/sgf-tempo/run/modular-regression-current.json','utf8')),tag=process.argv[2];if(s.port!==55439||!/^[a-f0-9]{12}$/.test(tag||''))throw Error('Wrong QA source');
|
||||
const root=path.join(s.root,'private','stock-receipt-'+tag),app=path.join(root,'app'),native=path.resolve('../compras'),config=path.join(root,'return-phpstan.neon');const state=JSON.parse(fs.readFileSync(path.join(root,'state.json'),'utf8'));if(path.resolve(state.app)!==path.resolve(app))throw Error('Unexpected app root');
|
||||
const quote=p=>JSON.stringify(p.replaceAll('\\','/'));fs.writeFileSync(config,`includes:\n - ${quote(path.join(native,'phpstan.neon'))}\nparameters:\n tmpDir: ${quote(path.join(root,'phpstan-return-cache'))}\n`);
|
||||
const paths=['app/Services/WarehouseReturns.php','app/Services/WarehouseReturnMovement.php','app/Http/Controllers/WarehouseReturnController.php'];
|
||||
const r=spawnSync('C:/laragon/tools/sgf-tempo/php-8.4.27/php.exe',[path.join(native,'vendor/phpstan/phpstan/phpstan'),'analyse','--configuration',config,'--error-format=json','--no-progress','--memory-limit=1G',...paths],{cwd:app,encoding:'utf8',windowsHide:true,maxBuffer:4*1024*1024,env:{...process.env,APP_ENV:'testing',APP_BASE_PATH:app,SGF_ISOLATED_REGRESSION_RUN:s.id}});
|
||||
fs.writeFileSync(path.join(root,'return-phpstan.json'),r.stdout||'');fs.writeFileSync(path.join(root,'return-phpstan-stderr.txt'),r.stderr||'');console.log(r.stdout);if(r.stderr)console.log(r.stderr);process.exitCode=r.status??1;
|
||||
@@ -0,0 +1,11 @@
|
||||
import fs from 'node:fs';import path from 'node:path';import crypto from 'node:crypto';import assert from 'node:assert/strict';import {createRequire} from 'node:module';import {pathToFileURL} from 'node:url';
|
||||
const state=JSON.parse(fs.readFileSync('C:/laragon/data/sgf-tempo/run/modular-regression-current.json','utf8')),tag=process.argv[2];if(state.port!==55439||!/^[a-f0-9]{12}$/.test(tag||''))throw Error('Wrong private source');
|
||||
const root=path.join(state.root,'private','stock-receipt-'+tag),app=path.join(root,'app'),native=path.resolve('../compras'),privateState=JSON.parse(fs.readFileSync(path.join(root,'state.json'),'utf8'));if(path.resolve(privateState.app)!==path.resolve(app))throw Error('Wrong QA app');
|
||||
const require=createRequire(path.join(app,'package.json')),React=require('react'),{renderToString}=require('react-dom/server');const {createInertiaApp}=await import(pathToFileURL(path.join(native,'node_modules/@inertiajs/react/dist/index.js')));const {createServer}=await import(pathToFileURL(path.join(native,'node_modules/vite-plus/dist/index.js')));
|
||||
const server=await createServer({root:app,configFile:false,cacheDir:path.join(root,'return-render-cache'),resolve:{alias:{'@':path.join(app,'resources/js')},dedupe:['react','react-dom','@inertiajs/react']},server:{middlewareMode:true,watch:null},appType:'custom',optimizeDeps:{noDiscovery:true}});
|
||||
try{
|
||||
const file=path.join(root,'native-stock-history-page.json'),page=JSON.parse(fs.readFileSync(file,'utf8'));assert.equal(page.component,'stock/operations');assert.equal(page.props.operations.data[0].kind,'workshop_return');
|
||||
const {default:Operations}=await server.ssrLoadModule('/resources/js/pages/stock/operations.tsx');const result=await createInertiaApp({page,render:renderToString,resolve:()=>Operations,setup:({App,props})=>React.createElement(App,props)});
|
||||
assert(result.body.includes('Devolução de excedente'));assert(result.body.includes('Registado por'));assert(result.body.includes('Responsável:'));
|
||||
const hash=p=>crypto.createHash('sha256').update(fs.readFileSync(p)).digest('hex');const proof={kind:'server_render_of_actual_native_stock_history_props',page_sha256:hash(file),component_sha256:hash(path.join(app,'resources/js/pages/stock/operations.tsx')),render_passed:true,return_human_label_visible:true,author_and_responsible_rendered:true,actual_http_props_used:true,visual_review:false,browser_interactions_tested:false};fs.writeFileSync('docs/proofs/native-return-history-render-20261102.json',JSON.stringify(proof,null,2)+'\n');console.log(JSON.stringify(proof));
|
||||
}finally{await server.close();}
|
||||
@@ -0,0 +1,20 @@
|
||||
import fs from 'node:fs';import path from 'node:path';import {pathToFileURL} from 'node:url';import crypto from 'node:crypto';
|
||||
const s=JSON.parse(fs.readFileSync('C:/laragon/data/sgf-tempo/run/modular-regression-current.json','utf8')),tag=process.argv[2];if(s.port!==55439||!/^[a-f0-9]{12}$/.test(tag||''))throw Error('Wrong QA app');
|
||||
const root=path.join(s.root,'private','stock-receipt-'+tag),app=path.join(root,'app'),state=JSON.parse(fs.readFileSync(path.join(root,'state.json'),'utf8'));
|
||||
if(path.resolve(state.app)!==path.resolve(app)||state.database!=='compras_receipts_test_'+tag)throw Error('Unexpected isolated build target');
|
||||
const native=path.resolve('../compras');if(!fs.existsSync(path.join(app,'node_modules')))fs.symlinkSync(path.join(native,'node_modules'),path.join(app,'node_modules'),'junction');
|
||||
fs.copyFileSync(path.join(native,'artisan'),path.join(app,'artisan'));
|
||||
const config=path.join(app,'vite.return-qa.config.ts');
|
||||
fs.writeFileSync(config,`import inertia from '@inertiajs/vite';
|
||||
import {wayfinder} from '@laravel/vite-plugin-wayfinder';
|
||||
import tailwindcss from '@tailwindcss/vite';
|
||||
import react from '@vitejs/plugin-react';
|
||||
import laravel from 'laravel-vite-plugin';
|
||||
import {defineConfig} from 'vite-plus';
|
||||
export default defineConfig({build:{emptyOutDir:false},cacheDir:${JSON.stringify(path.join(root,'vite-full-return-cache'))},plugins:[laravel({input:['resources/css/app.css','resources/js/app.tsx'],refresh:false}),inertia(),react(),tailwindcss(),wayfinder({formVariants:true})],resolve:{alias:{'@':${JSON.stringify(path.join(app,'resources/js'))}}}});
|
||||
`);
|
||||
process.env.PATH='C:/laragon/tools/sgf-tempo/php-8.4.27;C:/laragon/tools/sgf-tempo/node-v24.22.1-win-arm64;'+process.env.PATH;process.env.APP_ENV='testing';process.env.APP_BASE_PATH=app;process.env.SGF_ISOLATED_REGRESSION_RUN=s.id;process.chdir(app);
|
||||
const {build}=await import(pathToFileURL(path.join(native,'node_modules/vite-plus/dist/index.js')));await build({root:app,configFile:config});
|
||||
const file=path.join(app,'public/build/manifest.json'),manifest=JSON.parse(fs.readFileSync(file,'utf8'));if(!manifest['resources/js/pages/stock/returns.tsx'])throw Error('Return page absent from full QA manifest');
|
||||
const proof={kind:'private_application_asset_build_with_qa_configuration',passed:true,app_path_private:true,manifest_sha256:crypto.createHash('sha256').update(fs.readFileSync(file)).digest('hex'),return_page_in_manifest:true,configuration_difference:'Font downloading and optional React compiler omitted in QA; application entries, Inertia, React JSX, Tailwind and Wayfinder built. Does not validate the default optimized build.',native_public_build_touched:false,demo_assets_touched:false,visual_review:false};
|
||||
fs.writeFileSync(path.join(root,'return-private-build.json'),JSON.stringify(proof,null,2)+'\n');console.log(JSON.stringify(proof));
|
||||
@@ -0,0 +1,13 @@
|
||||
import fs from 'node:fs';import path from 'node:path';import crypto from 'node:crypto';import {spawnSync} from 'node:child_process';import {pathToFileURL} from 'node:url';
|
||||
const state=JSON.parse(fs.readFileSync('C:/laragon/data/sgf-tempo/run/modular-regression-current.json','utf8'));const tag=process.argv[2];if(!/^[a-f0-9]{12}$/.test(tag||'')||state.port!==55439)throw Error('Wrong QA tag');
|
||||
const root=path.join(state.root,'private','stock-receipt-'+tag),app=path.join(root,'app'),source=path.resolve('../compras');const owned=JSON.parse(fs.readFileSync(path.join(root,'state.json'),'utf8'));if(path.resolve(owned.app)!==path.resolve(app)||owned.database!=='compras_receipts_test_'+tag)throw Error('Wrong private QA app');
|
||||
const hash=p=>crypto.createHash('sha256').update(fs.readFileSync(p)).digest('hex');const inputs=['resources/js/pages/stock/returns.tsx','resources/js/pages/stock/request.tsx','resources/js/pages/stock/operations.tsx'];for(const p of inputs)if(hash(path.join(source,p))!==hash(path.join(app,p)))throw Error('UI copy changed since test staging');
|
||||
for(const f of ['tsconfig.json','package.json'])fs.copyFileSync(path.join(source,f),path.join(app,f));
|
||||
if(!fs.existsSync(path.join(app,'node_modules')))fs.symlinkSync(path.join(source,'node_modules'),path.join(app,'node_modules'),'junction');
|
||||
fs.writeFileSync(path.join(app,'tsconfig.return-ui.json'),JSON.stringify({extends:'./tsconfig.json',compilerOptions:{noEmit:true,incremental:false},include:inputs,exclude:[]},null,2));
|
||||
const types=spawnSync(process.execPath,[path.join(source,'node_modules/typescript/bin/tsc'),'-p',path.join(app,'tsconfig.return-ui.json')],{cwd:app,encoding:'utf8',windowsHide:true});fs.writeFileSync(path.join(root,'return-ui-types.txt'),(types.stdout||'')+(types.stderr||''));
|
||||
const {build}=await import(pathToFileURL(path.join(source,'node_modules/vite-plus/dist/index.js')));
|
||||
await build({root:app,configFile:false,cacheDir:path.join(root,'vite-return-cache'),resolve:{alias:{'@':path.join(app,'resources/js')}},build:{outDir:path.join(root,'return-ui-bundle'),emptyOutDir:false,lib:{entry:Object.fromEntries(inputs.map(p=>[path.basename(p,'.tsx'),path.join(app,p)])),formats:['es']},rollupOptions:{external:['react','react/jsx-runtime','@inertiajs/react']}}});
|
||||
const code=fs.readFileSync(path.join(app,inputs[0]),'utf8');if(!code.includes('name="declared_unused" required')||!code.includes('name="received_in_warehouse" required')||!code.includes('defaultValue="" required'))throw Error('Explicit declaration/receiving/selection controls missing');
|
||||
const report={kind:'isolated_changed_entry_browser_bundle_and_types',inputs_sha256:Object.fromEntries(inputs.map(p=>[p,hash(path.join(app,p))])),entry_bundle_passed:true,type_check_exit:types.status,type_output_sha256:hash(path.join(root,'return-ui-types.txt')),explicit_source_selection:true,unused_declaration_required_in_source:true,independent_receiving_declaration_required_in_source:true,live_or_demo_build_touched:false,full_application_build:false,browser_interactions_tested:false,visual_review:false};
|
||||
fs.writeFileSync('docs/proofs/native-return-ui-20261102.json',JSON.stringify(report,null,2)+'\n');console.log(JSON.stringify(report,null,2));if(types.status!==0){console.log((types.stdout||'')+(types.stderr||''));process.exitCode=1;}
|
||||
@@ -0,0 +1,7 @@
|
||||
import fs from 'node:fs';import path from 'node:path';import crypto from 'node:crypto';
|
||||
const source=path.resolve('packages/inventory-issue'),target=path.resolve('../compras/packages/inventory-issue/releases/0.1.0');
|
||||
const sha=p=>crypto.createHash('sha256').update(fs.readFileSync(p)).digest('hex');
|
||||
const files=['bootstrap.php','src/IssueMovementWriter.php'];const manifest={id:'tempo-inventory-issue',version:'0.1.0',files:Object.fromEntries(files.map(f=>[f,sha(path.join(source,f))]))},body=JSON.stringify(manifest,null,2)+'\n';
|
||||
if(fs.existsSync(target)){if(fs.readFileSync(path.join(target,'manifest.json'),'utf8')!==body)throw Error('Immutable artifact differs');for(const f of files)if(sha(path.join(target,f))!==manifest.files[f])throw Error('Artifact changed');}
|
||||
else{for(const f of files){fs.mkdirSync(path.dirname(path.join(target,f)),{recursive:true});fs.copyFileSync(path.join(source,f),path.join(target,f));}fs.writeFileSync(path.join(target,'manifest.json'),body);}
|
||||
console.log(JSON.stringify({id:manifest.id,manifest_sha256:sha(path.join(target,'manifest.json')),purchasing_activation:false}));
|
||||
@@ -0,0 +1,16 @@
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import crypto from 'node:crypto';
|
||||
const source=path.resolve('packages/inventory-read'),target=path.resolve('../compras/packages/inventory-read/releases/0.1.0');
|
||||
const sha=b=>crypto.createHash('sha256').update(b).digest('hex');
|
||||
const files=['bootstrap.php','src/InventoryReader.php','src/InventoryReadSession.php'];
|
||||
const manifest={id:'tempo-inventory-read',version:'0.1.0',files:Object.fromEntries(files.map(p=>[p,sha(fs.readFileSync(path.join(source,p)))]))};
|
||||
const body=JSON.stringify(manifest,null,2)+'\n';
|
||||
if(fs.existsSync(target)){
|
||||
if(fs.readFileSync(path.join(target,'manifest.json'),'utf8')!==body)throw Error('Immutable local artifact differs; create another version instead of replacing it');
|
||||
for(const p of files)if(sha(fs.readFileSync(path.join(target,p)))!==manifest.files[p])throw Error('Installed development artifact altered');
|
||||
}else{
|
||||
for(const p of files){fs.mkdirSync(path.dirname(path.join(target,p)),{recursive:true});fs.copyFileSync(path.join(source,p),path.join(target,p));}
|
||||
fs.writeFileSync(path.join(target,'manifest.json'),body);
|
||||
}
|
||||
console.log(JSON.stringify({package:manifest.id,version:manifest.version,manifest_sha256:sha(Buffer.from(body)),files:files.length,purchasing_activation:false}));
|
||||
@@ -0,0 +1,7 @@
|
||||
import fs from 'node:fs';import path from 'node:path';import crypto from 'node:crypto';
|
||||
const source=path.resolve('packages/inventory-return'),target=path.resolve('../compras/packages/inventory-return/releases/0.1.0');
|
||||
const sha=p=>crypto.createHash('sha256').update(fs.readFileSync(p)).digest('hex');
|
||||
const files=['bootstrap.php','src/DeliveryReturnWriter.php'];const manifest={id:'tempo-inventory-return',version:'0.1.0',files:Object.fromEntries(files.map(f=>[f,sha(path.join(source,f))]))},body=JSON.stringify(manifest,null,2)+'\n';
|
||||
if(fs.existsSync(target)){if(fs.readFileSync(path.join(target,'manifest.json'),'utf8')!==body)throw Error('Immutable return artifact differs');for(const f of files)if(sha(path.join(target,f))!==manifest.files[f])throw Error('Return artifact changed');}
|
||||
else{for(const f of files){fs.mkdirSync(path.dirname(path.join(target,f)),{recursive:true});fs.copyFileSync(path.join(source,f),path.join(target,f));}fs.writeFileSync(path.join(target,'manifest.json'),body);}
|
||||
console.log(JSON.stringify({id:manifest.id,manifest_sha256:sha(path.join(target,'manifest.json')),native_return_command_integrated:false,purchasing_activation:false}));
|
||||
@@ -0,0 +1,16 @@
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import crypto from 'node:crypto';
|
||||
const source=path.resolve('packages/inventory-write'),target=path.resolve('../compras/packages/inventory-write/releases/0.1.0');
|
||||
const sha=p=>crypto.createHash('sha256').update(fs.readFileSync(p)).digest('hex');
|
||||
const files=['bootstrap.php','src/ReceiptMovementWriter.php'];
|
||||
const manifest={id:'tempo-inventory-receipt-write',version:'0.1.0',files:Object.fromEntries(files.map(p=>[p,sha(path.join(source,p))]))};
|
||||
const body=JSON.stringify(manifest,null,2)+'\n';
|
||||
if(fs.existsSync(target)){
|
||||
if(fs.readFileSync(path.join(target,'manifest.json'),'utf8')!==body)throw Error('Refused to replace immutable development artifact');
|
||||
for(const file of files)if(sha(path.join(target,file))!==manifest.files[file])throw Error('Development artifact changed');
|
||||
}else{
|
||||
for(const file of files){fs.mkdirSync(path.dirname(path.join(target,file)),{recursive:true});fs.copyFileSync(path.join(source,file),path.join(target,file));}
|
||||
fs.writeFileSync(path.join(target,'manifest.json'),body);
|
||||
}
|
||||
console.log(JSON.stringify({id:manifest.id,version:manifest.version,manifest_sha256:sha(path.join(target,'manifest.json')),purchasing_activation:false}));
|
||||
@@ -0,0 +1,71 @@
|
||||
<?php
|
||||
// Included only after the strict owned-database guard in payable-history-sgf-proof.php.
|
||||
use App\Modules\Purchasing\{SupplierPayables,SupplierHistoryReceipts};
|
||||
use App\Shared\Context\CompanyContext;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Str;
|
||||
use Tempo\Finance\Payables\{Payables,HistoricalPayments};
|
||||
|
||||
$fixture=json_decode(file_get_contents($s['root'].'/fixture.json'),true,64,JSON_THROW_ON_ERROR);$scope=$fixture['scope'];$local=$fixture['source_id'];$service=app(SupplierPayables::class);$checks=0;$cases=[];
|
||||
if(DB::table('pa_obligations')->exists())throw new LogicException('Use a new owned QA fixture for regression; retained recognised evidence must not be reset.');
|
||||
config(['supplier_payable_history.synthetic_enabled'=>true]);
|
||||
$assert=function(bool $condition,string $message)use(&$checks){$checks++;if(!$condition)throw new RuntimeException($message);};
|
||||
$acting=function(string $name)use($fixture,$scope){$u=App\Models\User::findOrFail($fixture['actors'][$name]);auth()->setUser($u);app(CompanyContext::class)->selectFor($u->id,$scope['tenant_id'],$scope['company_id'],$scope['unit_id']);return $u;};
|
||||
$command=function(string $name,string $action,int $id,?string $key=null)use($acting,$service){return $service->engine($acting($name))->command($action,$id,'Synthetic exact history conference.', $key??(string)Str::uuid());};
|
||||
$refused=function(callable $call,int|string $expected)use($assert){try{$call();throw new RuntimeException('Expected refusal was not raised');}catch(\Symfony\Component\HttpKernel\Exception\HttpExceptionInterface $e){$assert($e->getStatusCode()===$expected,'Unexpected HTTP refusal');}catch(\DomainException $e){$assert($expected==='domain','Unexpected domain refusal');}};
|
||||
$case=function(string $name,callable $call)use(&$cases,&$checks){$before=$checks;DB::beginTransaction();try{$call();$cases[]=['name'=>$name,'assertions'=>$checks-$before,'passed'=>true];}finally{while(DB::transactionLevel()>0)DB::rollBack();config(['supplier_payable_history.synthetic_enabled'=>true]);}echo 'Passed: '.$name."\n";};
|
||||
$receipt=function(array $change=[],?string $hash=null)use($scope,$local){$rows=DB::table('pa_source_histories')->where($scope)->where('local_source_id',$local);$version=(int)(clone $rows)->max('version')+1;$payload=json_decode((clone $rows)->where('version',1)->value('payload'),true,64,JSON_THROW_ON_ERROR);$payload=array_replace($payload,['receipt_version'=>$version],$change);DB::table('pa_source_histories')->insert($scope+['local_source_id'=>$local,'version'=>$version,'payload'=>Payables::canonical($payload),'receipt_hash'=>$hash??hash('sha256',Payables::canonical($payload)),'created_at'=>now()]);};
|
||||
$case('default-disabled-and-old-path-preserved',function()use($assert,$service,$acting,$command,$refused,$local){
|
||||
config(['supplier_payable_history.synthetic_enabled'=>false]);$assert(!$service->listing($acting('read'))['history_import_available'],'History must remain disabled');$refused(fn()=>$command('prepare','prepare_history',$local),'domain');$r=$command('prepare','prepare',$local);$assert($r['state']==='pending_source','Old path must retain payment-history refusal');$assert(DB::table('pa_obligations')->count()===0,'No debt on disabled path');
|
||||
});
|
||||
$case('principal-confirmed-only-and-idempotency',function()use($assert,$service,$acting,$command,$refused,$local){
|
||||
$p=$command('prepare','prepare_history',$local);$assert($p['state']==='prepared','History preparation');$assert(DB::table('pa_obligations')->count()===0,'Preparation must not recognise');
|
||||
$refused(fn()=>$command('prepare','validate_history',$p['proposal_id']),403);
|
||||
$u=$acting('prepare');$scope=app(CompanyContext::class)->identifiers()+['unit_id'=>app(CompanyContext::class)->unitId()];DB::table('pa_access')->insert($scope+['user_id'=>$u->id,'person'=>'sgf-history-person-'.$u->id,'action'=>'validate']);$refused(fn()=>$command('prepare','validate_history',$p['proposal_id']),'domain');
|
||||
$key=(string)Str::uuid();$r=$command('validate','validate_history',$p['proposal_id'],$key);$assert($r['state']==='recognised','History recognition');$assert($command('validate','validate_history',$p['proposal_id'],$key)['replayed'],'Replay same result');$command('validate','validate_history',$p['proposal_id']);
|
||||
$d=$service->listing($acting('read'));$o=$d['obligations'][0];foreach(['principal_minor'=>6000,'balance_minor'=>3000,'confirmed_payments_minor'=>3000,'pending_payments_minor'=>1000,'rejected_payments_minor'=>500] as $k=>$value)$assert($o[$k]===$value,'Incorrect '.$k);
|
||||
$assert($o['due_on']===null&&$o['days_overdue']===null&&$o['due_status']==='unknown','No invented due date');$assert(DB::table('pa_obligations')->count()===1&&DB::table('pa_history_imports')->count()===1&&DB::table('pa_imported_payments')->count()===3,'One obligation/import, three original payments');$assert(!DB::getSchemaBuilder()->hasTable('pa_payment_decisions'),'No imported payment decisions installed');
|
||||
$html=Tempo\Finance\Payables\Workspace::render($d,['host'=>'SGF QA','actor'=>'Synthetic','organisation'=>'Synthetic','api'=>'/api/supplier-payables','csrf'=>'synthetic','context'=>'synthetic']);$assert(str_contains($html,'Saldo por liquidar: 30.00 MZN'),'History visible in existing UI');$assert(!str_contains(DB::table('pa_history_imports')->value('snapshot'),'TEST-PAY-ACCOUNT'),'Private bank data excluded');
|
||||
});
|
||||
$case('receipt-integrity-and-identity-refusals',function()use($assert,$receipt,$command,$refused,$local){
|
||||
foreach([[[] ,str_repeat('0',64)],[['source_key'=>str_repeat('0',64)],null],[['source_revision'=>str_repeat('0',64)],null],[['version'=>'unsupported'],null],[['history_hash'=>str_repeat('0',64)],null]] as [$change,$hash]){$receipt($change,$hash);$refused(fn()=>$command('prepare','prepare_history',$local),'domain');}
|
||||
$assert(DB::table('pa_obligations')->count()===0,'Invalid receipts must not recognise');
|
||||
});
|
||||
$case('complete-compatible-payment-facts-required',function()use($receipt,$command,$refused,$local,$assert){
|
||||
$row=json_decode(DB::table('pa_source_histories')->orderByDesc('version')->value('payload'),true,64,JSON_THROW_ON_ERROR);$original=$row['payments'];
|
||||
foreach(['missing','duplicate','company','currency','audit','self-review'] as $variant){$rows=$original;if($variant==='missing')array_pop($rows);if($variant==='duplicate')$rows[1]=$rows[0];if($variant==='company')$rows[0]['company']+=100;if($variant==='currency')$rows[0]['currency']='USD';if($variant==='audit')$rows[0]['registration_audit_id']=null;if($variant==='self-review')$rows[0]['reviewed_by']=$rows[0]['created_by'];$receipt(['payments'=>$rows]);$refused(fn()=>$command('prepare','prepare_history',$local),'domain');}
|
||||
$assert(DB::table('pa_imported_payments')->count()===0,'No partial history imports');
|
||||
});
|
||||
$case('changed-history-requires-new-conference',function()use($receipt,$command,$assert,$local){
|
||||
$p=$command('prepare','prepare_history',$local);$source=DB::table('pa_imported_sources')->where('id',$local)->first();$doc=json_decode($source->payload,true,64,JSON_THROW_ON_ERROR);$row=DB::table('pa_source_histories')->orderByDesc('version')->first();$payload=json_decode($row->payload,true,64,JSON_THROW_ON_ERROR);$payments=$payload['payments'];
|
||||
foreach($payments as &$payment)if($payment['status']==='pending'){$payment['status']='rejected';$payment['reviewed_by']=999;$payment['reviewed_at']='2026-11-02 12:00:00';$payment['decision_audit_id']=999;}unset($payment);
|
||||
$capture=HistoricalPayments::capture($doc,$payments);$receipt(['payments'=>$payments,'history_hash'=>$capture['history_hash']]);$r=$command('validate','validate_history',$p['proposal_id']);$assert($r['state']==='conflict','Changed history cannot confirm old proposal');$assert(DB::table('pa_obligations')->count()===0,'No debt from stale conference');$assert(DB::table('pa_proposals')->count()===1,'Old proposal preserved');
|
||||
});
|
||||
$case('permissions-person-and-context-revalidated',function()use($command,$acting,$refused,$scope,$fixture,$service,$local,$assert){
|
||||
$p=$command('prepare','prepare_history',$local);DB::table('pa_access')->where($scope)->where(['user_id'=>$fixture['actors']['validate'],'action'=>'validate'])->delete();$refused(fn()=>$command('validate','validate_history',$p['proposal_id']),403);
|
||||
$u=$acting('read');DB::table('permission_grants')->where(['user_id'=>$u->id,'permission'=>'supplier_payables.view'])->delete();$refused(fn()=>$service->document($u,$local),403);
|
||||
$u=$acting('prepare');$refused(fn()=>app(CompanyContext::class)->selectFor($u->id,$scope['tenant_id'],$scope['company_id']+100,$scope['unit_id']),403);
|
||||
$acting('prepare');$engine=$service->engine($u);auth()->setUser(App\Models\User::findOrFail($fixture['actors']['validate']));$refused(fn()=>$engine->command('prepare_history',$local,'Synthetic stale actor.',(string)Str::uuid()),403);$assert(DB::table('pa_obligations')->count()===0,'No unauthorised debt');
|
||||
});
|
||||
$case('audit-and-import-failure-atomic-recovery',function()use($command,$assert,$local){
|
||||
$p=$command('prepare','prepare_history',$local);$fault=true;DB::listen(function($q)use(&$fault){if($fault&&str_contains($q->sql,'insert into "pa_imported_payments"')){$fault=false;throw new RuntimeException('Synthetic import failure');}});
|
||||
try{$command('validate','validate_history',$p['proposal_id']);throw new RuntimeException('Fault was not raised');}catch(RuntimeException $e){$assert($e->getMessage()==='Synthetic import failure','Expected injected failure');}
|
||||
$assert(DB::table('pa_obligations')->count()===0&&DB::table('pa_history_imports')->count()===0&&DB::table('pa_imported_payments')->count()===0,'Import rollback must be complete');$command('validate','validate_history',$p['proposal_id']);$assert(DB::table('pa_obligations')->count()===1&&DB::table('pa_imported_payments')->count()===3,'Explicit retry creates one complete import');
|
||||
});
|
||||
$case('receipt-append-only-and-existing-controller',function()use($assert,$command,$acting,$service,$local){
|
||||
try{DB::transaction(fn()=>DB::table('pa_source_histories')->update(['version'=>99]));throw new RuntimeException('Update should fail');}catch(Illuminate\Database\QueryException $e){$assert($e->errorInfo[0]==='P0001','Immutable receipt');}
|
||||
$u=$acting('prepare');$request=Illuminate\Http\Request::create('/api/supplier-payables/prepare_history','POST',['id'=>$local,'reason'=>'Synthetic controller history preparation.']);$request->headers->set('Idempotency-Key',(string)Str::uuid());$request->setUserResolver(fn()=>$u);$response=app(App\Modules\Purchasing\SupplierPayablesController::class)->command($request,'prepare_history',$service);$assert($response->getStatusCode()===200&&$response->getData(true)['state']==='prepared','Existing controller delegates historical command');
|
||||
$match=app('router')->getRoutes()->match(Illuminate\Http\Request::create('/api/supplier-payables/validate_history','POST'));$assert(str_contains($match->getActionName(),'SupplierPayablesController@command'),'History route uses existing controller');
|
||||
});
|
||||
$case('invalid-receipt-keeps-history-readable-and-balance-indeterminate',function()use($assert,$command,$receipt,$service,$acting,$local){
|
||||
$p=$command('prepare','prepare_history',$local);$command('validate','validate_history',$p['proposal_id']);$receipt([],str_repeat('0',64));$d=$service->listing($acting('read'));$o=$d['obligations'][0];$assert($o['balance_minor']===null&&$o['source_conflict']===true,'Divergent receipt is indeterminate, not zero');$assert($o['principal_minor']===6000&&count($d['documents'])===1&&$d['events']->count()>0,'Original and evidence remain readable');
|
||||
});
|
||||
$case('concurrent-confirmations-wait-and-replay-one-import',function()use($assert,$command,$local,$s,$file){
|
||||
$p=$command('prepare','prepare_history',$local);$key=(string)Str::uuid();$command('validate','validate_history',$p['proposal_id'],$key);$input=$s['root'].'/worker.json';$application='history_'.$s['tag'];file_put_contents($input,json_encode(['proposal'=>$p['proposal_id'],'key'=>$key,'application'=>$application],JSON_THROW_ON_ERROR));$process=null;
|
||||
try{$pipes=[];$process=proc_open([PHP_BINARY,__DIR__.'/payable-history-sgf-proof.php',$file,'worker',$input],[0=>['pipe','r'],1=>['file',$s['root'].'/worker-stdout.txt','w'],2=>['file',$s['root'].'/worker-stderr.txt','w']],$pipes,dirname(__DIR__));$assert(is_resource($process),'Worker launched');fclose($pipes[0]);$parent=(int)DB::selectOne('SELECT pg_backend_pid() AS id')->id;$wait=null;$deadline=microtime(true)+10;
|
||||
do{DB::select('SELECT pg_stat_clear_snapshot()');$wait=DB::selectOne("SELECT to_json(pg_blocking_pids(pid)) AS blockers FROM pg_stat_activity WHERE application_name=? AND wait_event_type='Lock'",[$application]);if(!$wait)usleep(25000);}while(!$wait&µtime(true)<$deadline);$assert($wait!==null,'Concurrent company blocker observed');$assert(in_array($parent,json_decode($wait->blockers,true),true),'First transaction blocks second');DB::commit();
|
||||
$deadline=microtime(true)+15;do{$status=proc_get_status($process);if(!$status['running'])break;usleep(25000);}while(microtime(true)<$deadline);$assert(!$status['running'],'Worker completed after release');$exit=proc_close($process);$process=null;if($exit===-1)$exit=$status['exitcode'];$assert($exit===0,'Worker exited normally');$result=json_decode(file_get_contents($input.'.result'),true,64,JSON_THROW_ON_ERROR);$assert($result['status']===200&&$result['result']['replayed']===true,'Concurrent replay original result');$assert(DB::table('pa_obligations')->count()===1&&DB::table('pa_history_imports')->count()===1&&DB::table('pa_imported_payments')->count()===3,'One obligation, one import, three payments');
|
||||
}finally{if(is_resource($process)){proc_terminate($process);proc_close($process);}}
|
||||
});
|
||||
$report=['kind'=>'sgf_historical_payable_adapter_synthetic','cases'=>$cases,'tests'=>count($cases),'assertions'=>$checks,'principal_minor'=>6000,'confirmed_minor'=>3000,'pending_minor'=>1000,'rejected_minor'=>500,'balance_minor'=>3000,'shared_engine_unchanged'=>true,'native_source_preparation_only'=>true,'actual_http_kernel_tested'=>false,'browser_interactions_tested'=>false,'visual_review'=>false,'deployment_capacity_validated'=>false];
|
||||
file_put_contents($s['root'].'/regression.json',json_encode($report,JSON_PRETTY_PRINT|JSON_THROW_ON_ERROR));echo json_encode(['tests'=>count($cases),'assertions'=>$checks,'passed'=>true])."\n";
|
||||
@@ -0,0 +1,4 @@
|
||||
<?php
|
||||
// Test-only router: private owned QA settings are validated before application bootstrap.
|
||||
$argv=[__FILE__,getenv('PAYABLE_HISTORY_QA_SETTINGS'),'http'];
|
||||
require __DIR__.'/payable-history-sgf-proof.php';
|
||||
@@ -0,0 +1,48 @@
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import net from 'node:net';
|
||||
import crypto from 'node:crypto';
|
||||
import assert from 'node:assert/strict';
|
||||
import {spawn} from 'node:child_process';
|
||||
|
||||
const read=p=>JSON.parse(fs.readFileSync(p,'utf8'));
|
||||
const state=read('C:/laragon/data/sgf-tempo/run/modular-regression-current.json'),tag=process.argv[2];
|
||||
if(state.port!==55439||!/^[a-f0-9]{12}$/.test(tag||''))throw Error('Wrong QA context');
|
||||
const root=path.join(state.root,'private/payable-history-'+tag),settings=path.join(root,'settings.json');
|
||||
const owned=read(settings);if(owned.tag!==tag||owned.cluster!=='sgf_modular_regression_'+state.id)throw Error('Wrong owned fixture');
|
||||
const port=8047;
|
||||
await new Promise((resolve,reject)=>{const probe=net.createServer();probe.once('error',()=>reject(Error('QA HTTP port occupied; no process stopped')));probe.listen(port,'127.0.0.1',()=>probe.close(resolve));});
|
||||
const log=fs.openSync(path.join(root,'http-server.log'),'a');
|
||||
const child=spawn('C:/laragon/tools/sgf-tempo/php-8.4.27/php.exe',['-S','127.0.0.1:'+port,path.resolve('scripts/payable-history-sgf-http-router.php')],{cwd:process.cwd(),env:{...process.env,PAYABLE_HISTORY_QA_SETTINGS:settings},windowsHide:true,stdio:['ignore',log,log]});
|
||||
let assertions=0;const check=(value,message)=>{assertions++;assert(value,message);};
|
||||
const client=()=>{const cookies=new Map();let auth;
|
||||
const request=async(url,body,options={})=>{
|
||||
const headers={Accept:'application/json',Cookie:[...cookies].map(([k,v])=>k+'='+v).join('; '),'X-SGF-Context':auth?.context??''};
|
||||
if(body){headers['Content-Type']='application/json';headers['X-CSRF-TOKEN']=auth?.csrf??'';headers['X-SGF-Context']=auth?.context??'';headers['Idempotency-Key']=options.key??crypto.randomUUID();}
|
||||
Object.assign(headers,options.headers??{});
|
||||
const r=await fetch('http://127.0.0.1:'+port+url,{method:body?'POST':'GET',headers,body:body?JSON.stringify(body):undefined,redirect:'manual',signal:AbortSignal.timeout(8000)});
|
||||
for(const cookie of r.headers.getSetCookie()){const pair=cookie.split(';')[0],i=pair.indexOf('=');cookies.set(pair.slice(0,i),pair.slice(i+1));}
|
||||
const text=await r.text();let data;try{data=JSON.parse(text);}catch{}return {status:r.status,data,text,headers:r.headers};
|
||||
};
|
||||
return {request,login:async role=>{const r=await request('/qa/enter/'+role);check(r.status===200,'QA role entry');auth=r.data;return auth;}};
|
||||
};
|
||||
try {
|
||||
const preparer=client();let ready=false;for(let n=0;n<50&&!ready;n++){try{await preparer.login('prepare');ready=true;}catch{await new Promise(resolve=>setTimeout(resolve,100));}}check(ready,'Owned HTTP host ready');
|
||||
const readOnly=client(),validator=client();const actor=await readOnly.login('read');await validator.login('validate');
|
||||
const listing=await readOnly.request('/api/supplier-payables');check(listing.status===200,'Authenticated history listing: '+listing.status+' '+(listing.data?.message??''));check(listing.data.history_import_available===true&&listing.data.history_decisions_available===false,'History linked without pending-payment decisions');
|
||||
const o=listing.data.obligations[0];check(o.principal_minor===6000&&o.balance_minor===3000&&o.pending_payments_minor===1000&&o.rejected_payments_minor===500,'Same authoritative monetary position');check(listing.headers.get('cache-control').includes('no-store'),'Private finance response');
|
||||
const body={id:actor.source_id,reason:'Synthetic HTTP historical source review.'};
|
||||
check((await readOnly.request('/api/supplier-payables/prepare_history',body)).status===403,'Reader cannot prepare');
|
||||
check((await preparer.request('/api/supplier-payables/prepare_history',body,{headers:{'X-SGF-Context':'obsolete'}})).status===409,'Obsolete context refused');
|
||||
check((await preparer.request('/api/supplier-payables/prepare_history',body,{headers:{'X-CSRF-TOKEN':''}})).status===419,'Missing CSRF refused');
|
||||
check((await preparer.request('/api/supplier-payables/prepare_history',{...body,company_id:999})).status===422,'Context injection refused');
|
||||
const key=crypto.randomUUID();const first=await preparer.request('/api/supplier-payables/prepare_history',body,{key});check(first.status===200&&first.data.state==='recognised','HTTP reuses recognised historical origin');
|
||||
const again=await preparer.request('/api/supplier-payables/prepare_history',body,{key});check(again.status===200&&again.data.replayed===true,'HTTP response loss can replay original');
|
||||
const page=await readOnly.request('/supplier-payables');check(page.status===200&&page.text.includes('Saldo por liquidar: 30.00 MZN')&&page.text.includes('sem abatimento'),'Existing HTML displays original history and balance');check(page.text.includes('Não há leitura histórica em tempo real nem passagem operacional de responsabilidade validada.'),'Receipt and operational cutover distinguished visibly');
|
||||
const final=await validator.request('/api/supplier-payables');check(final.status===200&&final.data.obligations.length===1&&final.data.obligations[0].balance_minor===3000,'No second debt after HTTP replay');
|
||||
const report={kind:'sgf_historical_payable_actual_http_kernel',assertions,passed:true,existing_html_reused:true,csrf_and_context_enforced:true,reader_write_refused:true,no_pending_payment_decision_route_added:true,one_obligation_same_balance_after_replay:true,visual_review:false,browser_interactions_tested:false};
|
||||
const output=path.join(root,'http-proof.json');if(fs.existsSync(output)){const prior=fs.readFileSync(output),archive=output+'.'+crypto.createHash('sha256').update(prior).digest('hex')+'.archive';if(!fs.existsSync(archive))fs.writeFileSync(archive,prior,{flag:'wx'});}fs.writeFileSync(output,JSON.stringify(report,null,2)+'\n');console.log(JSON.stringify(report));
|
||||
} finally {
|
||||
if(child.exitCode===null){child.kill();await new Promise(resolve=>child.once('exit',resolve));}
|
||||
fs.closeSync(log);
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
<?php
|
||||
// Owned fresh databases only; no installed connection, source/demonstration reset or cleanup.
|
||||
$file=realpath($argv[1]??'');$state=json_decode(file_get_contents('C:/laragon/data/sgf-tempo/run/modular-regression-current.json'),true,512,JSON_THROW_ON_ERROR);
|
||||
$private=realpath($state['root'].'/private');
|
||||
if(!$file||!$private||!str_starts_with($file,$private.DIRECTORY_SEPARATOR)||$state['port']!==55439)throw new LogicException('QA settings refused');
|
||||
$s=json_decode(file_get_contents($file),true,512,JSON_THROW_ON_ERROR);$phase=$argv[2]??'';$native=in_array($phase,['native','native-post'],true);$mode=$native?'native':'sgf';$c=$s[$mode];
|
||||
if(!preg_match('/^[a-f0-9]{12}$/D',$s['tag'])||$c['database']!==($native?'compras_receipts_test_':'payable_sgf_').$s['tag']||$c['role']!=='payable_history_'.$mode.'_'.$s['tag'])throw new LogicException('QA identity refused');
|
||||
$env=['APP_ENV'=>$native?'testing':'payable-testing','APP_DEBUG'=>'false','APP_KEY'=>'base64:'.base64_encode(str_repeat('s',32)),'DB_CONNECTION'=>'pgsql','DB_URL'=>'','DB_HOST'=>'127.0.0.1','DB_PORT'=>'55439','DB_DATABASE'=>$c['database'],'DB_USERNAME'=>$c['role'],'DB_PASSWORD'=>$c['password'],'SESSION_DRIVER'=>'array','CACHE_STORE'=>'array','QUEUE_CONNECTION'=>'sync','MAIL_MAILER'=>'array','BCRYPT_ROUNDS'=>'4','LARAVEL_STORAGE_PATH'=>$c['storage'],'APP_CONFIG_CACHE'=>$c['storage'].'/cache/config.php','APP_ROUTES_CACHE'=>$c['storage'].'/cache/routes.php','APP_SERVICES_CACHE'=>$c['storage'].'/cache/services.php','APP_PACKAGES_CACHE'=>$c['storage'].'/cache/packages.php','VIEW_COMPILED_PATH'=>$c['storage'].'/framework/views','COMMON_PAYABLES_PATH'=>dirname(__DIR__).'/packages/payables','COMMON_PAYABLES_ENABLED'=>'true','COMMON_PAYABLES_HISTORY_ENABLED'=>'true','PAYABLE_SOURCE_INSTANCE'=>$s['source_instance']];
|
||||
foreach($env as $k=>$v){putenv($k.'='.$v);$_ENV[$k]=$_SERVER[$k]=$v;}
|
||||
if($phase==='http'){putenv('SESSION_DRIVER=file');$_ENV['SESSION_DRIVER']=$_SERVER['SESSION_DRIVER']='file';}
|
||||
require $c['root'].'/vendor/autoload.php';$app=require $c['root'].'/bootstrap/app.php';$app->useStoragePath($c['storage']);$app->addAbsoluteCachePathPrefix('C:');$app->make(Illuminate\Contracts\Console\Kernel::class)->bootstrap();
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Str;
|
||||
use Tempo\Finance\Payables\{Payables,HistoricalPayments,HistorySchema};
|
||||
$i=DB::selectOne("SELECT current_database() AS db,current_user AS role,inet_server_port() AS port,current_setting('cluster_name') AS cluster");
|
||||
if($i->db!==$c['database']||$i->role!==$c['role']||(int)$i->port!==55439||$i->cluster!==$s['cluster'])throw new LogicException('Effective QA host refused');
|
||||
$role=DB::selectOne('SELECT rolsuper OR rolcreatedb OR rolcreaterole OR rolreplication OR rolbypassrls AS elevated FROM pg_roles WHERE rolname=current_user');if($role->elevated)throw new LogicException('Elevated role refused');
|
||||
function fingerprint():string {$rows=[];foreach(['invoices','invoice_items','external_payments','budget_entries','budget_consumptions','stock_movements'] as $t)$rows[$t]=DB::table($t)->orderBy('id')->get()->map(fn($r)=>(array)$r)->all();return hash('sha256',Payables::canonical($rows));}
|
||||
if($phase==='native'){
|
||||
if(DB::getSchemaBuilder()->hasTable('users'))throw new LogicException('Fresh native database required');
|
||||
Illuminate\Support\Facades\Artisan::call('migrate',['--force'=>true]);require $c['root'].'/tests/Support/PaymentFixture.php';$f=paymentFixture();
|
||||
foreach([$f['author'],$f['validator']] as $u)foreach(['view','prepare','validate'] as $action)DB::table('pa_access')->insert(['tenant_id'=>$u->tenant_id,'company_id'=>0,'unit_id'=>0,'user_id'=>$u->id,'person'=>'native-history-person-'.$u->id,'action'=>$action]);
|
||||
foreach([['30.00','confirmed'],['10.00','pending'],['5.00','rejected']] as [$amount,$status]){
|
||||
auth()->setUser($f['author']);$p=app(App\Services\ExternalPaymentWorkflow::class)->register($f['author'],$f['invoice'],[...$f['payment_data'],'amount'=>$amount,'external_reference'=>'SIM-HISTORY-'.$status]);
|
||||
if($status!=='pending'){auth()->setUser($f['validator']);app(App\Services\ExternalPaymentWorkflow::class)->review($f['validator'],$p,$status,'Synthetic original historical decision.');}
|
||||
}
|
||||
auth()->setUser($f['author']);$service=app(App\Services\SupplierPayables::class);$doc=$service->document($f['author'],$f['invoice']->id);
|
||||
$proposal=$service->engine($f['author'])->command('prepare_history',$f['invoice']->id,'Synthetic historical receipt capture.',(string)Str::uuid());
|
||||
$snapshot=json_decode(DB::table('pa_proposals')->where('id',$proposal['proposal_id'])->value('snapshot'),true,64,JSON_THROW_ON_ERROR);
|
||||
$capture=$snapshot['_history_import'];$bundle=['source'=>$doc,'capture'=>$capture,'native_originals_sha256'=>fingerprint(),'native_obligations'=>DB::table('pa_obligations')->count()];
|
||||
file_put_contents($s['root'].'/source.json',Payables::canonical($bundle));echo "Native conferred invoice and original 30/10/5 history captured; no native obligation recognised.\n";exit;
|
||||
}
|
||||
if($phase==='native-post'){
|
||||
DB::beginTransaction();DB::statement('SET TRANSACTION READ ONLY');
|
||||
$bundle=json_decode(file_get_contents($s['root'].'/source.json'),true,64,JSON_THROW_ON_ERROR);if(fingerprint()!==$bundle['native_originals_sha256']||DB::table('pa_obligations')->count()!==0)throw new LogicException('Native originals changed');
|
||||
file_put_contents($s['root'].'/native-post.json',json_encode(['originals_unchanged'=>true,'originals_sha256'=>fingerprint(),'native_obligations'=>0,'payments'=>DB::table('external_payments')->orderBy('id')->pluck('status')->all(),'read_only_observation'=>true],JSON_PRETTY_PRINT));DB::rollBack();echo "Native originals and payment states preserved.\n";exit;
|
||||
}
|
||||
if($phase==='sgf'){
|
||||
if(DB::getSchemaBuilder()->hasTable('users'))throw new LogicException('Fresh SGF database required');
|
||||
foreach(['0001_01_01_000000_create_users_table.php','2026_10_25_000000_create_identity_foundation.php','2026_10_25_010000_add_identity_workflow.php'] as $m)(require $c['root'].'/database/migrations/'.$m)->up();
|
||||
Tempo\Finance\Payables\PayablesSchema::install();HistorySchema::install();
|
||||
DB::statement('ALTER TABLE permission_grants DROP CONSTRAINT permission_grants_scope_check');DB::statement("ALTER TABLE permission_grants ADD CONSTRAINT permission_grants_scope_check CHECK ((unit_id IS NULL AND permission='company.view') OR (unit_id IS NOT NULL AND permission IN ('unit.view','supplier_payables.view')))");
|
||||
$now=now();$tenant=DB::table('tenants')->insertGetId(['name'=>'Synthetic history tenant','created_at'=>$now,'updated_at'=>$now]);$company=DB::table('companies')->insertGetId(['tenant_id'=>$tenant,'name'=>'Synthetic history company','created_at'=>$now,'updated_at'=>$now]);$unit=DB::table('units')->insertGetId(['tenant_id'=>$tenant,'company_id'=>$company,'name'=>'Synthetic history unit','created_at'=>$now,'updated_at'=>$now]);$scope=['tenant_id'=>$tenant,'company_id'=>$company,'unit_id'=>$unit];$actors=[];
|
||||
foreach(['prepare'=>['view','prepare'],'validate'=>['view','validate'],'read'=>['view']] as $name=>$actions){$u=App\Models\User::create(['name'=>'Synthetic '.$name,'email'=>$name.'@history.example.test','password'=>bin2hex(random_bytes(16))]);$actors[$name]=$u->id;DB::table('company_memberships')->insert(['tenant_id'=>$tenant,'company_id'=>$company,'user_id'=>$u->id]);foreach(['company.view'=>null,'unit.view'=>$unit,'supplier_payables.view'=>$unit] as $p=>$which)DB::table('permission_grants')->insert([...$scope,'unit_id'=>$which,'user_id'=>$u->id,'permission'=>$p,'created_at'=>$now,'updated_at'=>$now]);foreach($actions as $action)DB::table('pa_access')->insert($scope+['user_id'=>$u->id,'action'=>$action,'person'=>'sgf-history-person-'.$u->id]);}
|
||||
DB::statement('CREATE TABLE pa_imported_sources(id bigserial PRIMARY KEY,tenant_id bigint,company_id bigint,unit_id bigint,payload jsonb,receipt_hash varchar(64))');
|
||||
DB::statement('CREATE TABLE pa_source_histories(id bigserial PRIMARY KEY,tenant_id bigint NOT NULL,company_id bigint NOT NULL,unit_id bigint NOT NULL,local_source_id bigint NOT NULL REFERENCES pa_imported_sources(id),version int NOT NULL CHECK(version>0),payload jsonb NOT NULL,receipt_hash varchar(64) NOT NULL,created_at timestamp NOT NULL,UNIQUE(tenant_id,company_id,unit_id,local_source_id,version))');
|
||||
DB::statement("CREATE FUNCTION qa_history_immutable() RETURNS trigger LANGUAGE plpgsql AS 'BEGIN RAISE EXCEPTION ''Immutable source history receipt''; END'");DB::statement('CREATE TRIGGER immutable BEFORE UPDATE OR DELETE ON pa_source_histories FOR EACH ROW EXECUTE FUNCTION qa_history_immutable()');
|
||||
$bundle=json_decode(file_get_contents($s['root'].'/source.json'),true,64,JSON_THROW_ON_ERROR);$doc=$bundle['source'];unset($doc['local_id']);$id=DB::table('pa_imported_sources')->insertGetId($scope+['payload'=>Payables::canonical($doc),'receipt_hash'=>hash('sha256',Payables::canonical($doc))]);
|
||||
$payload=['version'=>App\Modules\Purchasing\SupplierHistoryReceipts::VERSION,'receipt_version'=>1,'source_key'=>Payables::identity($doc),'source_revision'=>Payables::revision($doc),'history_hash'=>$bundle['capture']['history_hash'],'payments'=>$bundle['capture']['payments']];
|
||||
DB::table('pa_source_histories')->insert($scope+['local_source_id'=>$id,'version'=>1,'payload'=>Payables::canonical($payload),'receipt_hash'=>hash('sha256',Payables::canonical($payload)),'created_at'=>$now]);
|
||||
file_put_contents($s['root'].'/fixture.json',json_encode(['scope'=>$scope,'actors'=>$actors,'source_id'=>$id],JSON_PRETTY_PRINT));echo "SGF receipt and existing-right QA identities installed only in fresh isolated database.\n";exit;
|
||||
}
|
||||
if($phase==='worker'){
|
||||
$input=realpath($argv[3]??'');if(!$input||!str_starts_with($input,realpath($s['root']).DIRECTORY_SEPARATOR))throw new LogicException('Worker input refused');$w=json_decode(file_get_contents($input),true,64,JSON_THROW_ON_ERROR);$f=json_decode(file_get_contents($s['root'].'/fixture.json'),true,64,JSON_THROW_ON_ERROR);
|
||||
config(['supplier_payable_history.synthetic_enabled'=>true]);DB::select("SELECT set_config('application_name',?,false)",[$w['application']]);DB::statement("SET lock_timeout='8s'");$u=App\Models\User::findOrFail($f['actors']['validate']);auth()->setUser($u);app(App\Shared\Context\CompanyContext::class)->selectFor($u->id,$f['scope']['tenant_id'],$f['scope']['company_id'],$f['scope']['unit_id']);
|
||||
try{$r=app(App\Modules\Purchasing\SupplierPayables::class)->engine($u)->command('validate_history',$w['proposal'],'Synthetic exact history conference.',$w['key']);$out=['status'=>200,'result'=>$r];}catch(DomainException $e){$out=['status'=>409];}
|
||||
file_put_contents($input.'.result',json_encode($out,JSON_THROW_ON_ERROR));exit;
|
||||
}
|
||||
if($phase==='http'){
|
||||
config(['supplier_payable_history.synthetic_enabled'=>true]);$f=json_decode(file_get_contents($s['root'].'/fixture.json'),true,64,JSON_THROW_ON_ERROR);
|
||||
Illuminate\Support\Facades\Route::middleware('web')->get('/qa/enter/{role}',function(Illuminate\Http\Request $r,string $role)use($f){abort_unless(in_array($role,['prepare','validate','read'],true),404);$u=App\Models\User::findOrFail($f['actors'][$role]);auth()->login($u);$r->session()->regenerate();$token=bin2hex(random_bytes(24));$r->session()->put('auth_version',$u->auth_version);$r->session()->put('company_context',$f['scope']+['token'=>$token,'module_id'=>'purchasing']);return response()->json(['csrf'=>csrf_token(),'context'=>$token,'source_id'=>$f['source_id']])->header('Cache-Control','no-store');});
|
||||
$app->handleRequest(Illuminate\Http\Request::capture());exit;
|
||||
}
|
||||
if($phase==='post'){
|
||||
DB::beginTransaction();DB::statement('SET TRANSACTION READ ONLY');$f=json_decode(file_get_contents($s['root'].'/fixture.json'),true,64,JSON_THROW_ON_ERROR);$bundle=json_decode(file_get_contents($s['root'].'/source.json'),true,64,JSON_THROW_ON_ERROR);$source=$bundle['source'];unset($source['local_id']);$row=DB::table('pa_imported_sources')->where($f['scope'])->where('id',$f['source_id'])->first();$ob=DB::table('pa_obligations')->where($f['scope'])->first();$import=DB::table('pa_history_imports')->where($f['scope'])->first();
|
||||
if(!$row||!$ob||!$import||Payables::canonical($source)!==Payables::canonical(json_decode($row->payload,true))||DB::table('pa_obligations')->count()!==1||DB::table('pa_history_imports')->count()!==1||DB::table('pa_imported_payments')->count()!==3||(int)$ob->principal_minor!==6000||(int)$import->confirmed_minor!==3000||(int)$import->pending_minor!==1000||(int)$import->rejected_minor!==500||DB::table('pa_source_histories')->count()!==1||config('supplier_payable_history.synthetic_enabled')!==false)throw new LogicException('SGF final preservation check failed');
|
||||
file_put_contents($s['root'].'/sgf-post.json',json_encode(['read_only'=>true,'one_obligation'=>true,'one_history_import'=>true,'original_source_unchanged'=>true,'source_receipt_sha256'=>$row->receipt_hash,'imported_payments'=>3,'states'=>DB::table('pa_imported_payments')->orderBy('legacy_payment_id')->pluck('status')->all(),'recognition_events'=>DB::table('pa_events')->where('kind','payable.recognised')->count(),'history_events'=>DB::table('pa_events')->where('kind','payable.legacy_history_imported')->count(),'principal_minor'=>6000,'confirmed_minor'=>3000,'pending_minor'=>1000,'rejected_minor'=>500,'pending_decision_schema_installed'=>DB::getSchemaBuilder()->hasTable('pa_payment_decisions'),'default_feature_enabled'=>false],JSON_PRETTY_PRINT|JSON_THROW_ON_ERROR));DB::rollBack();echo "SGF final source, single import and default-disabled configuration verified read-only.\n";exit;
|
||||
}
|
||||
if($phase==='page-seed'){require __DIR__.'/payable-page-fixture.php';exit;}
|
||||
if($phase==='page-cases'){require __DIR__.'/payable-page-cases.php';exit;}
|
||||
if($phase==='page-bench'){require __DIR__.'/payable-page-bench.php';exit;}
|
||||
if($phase!=='regression')throw new LogicException('Unknown phase');
|
||||
require __DIR__.'/payable-history-sgf-cases.php';
|
||||
@@ -0,0 +1,34 @@
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import crypto from 'node:crypto';
|
||||
import {spawnSync} from 'node:child_process';
|
||||
|
||||
const read=p=>JSON.parse(fs.readFileSync(p,'utf8').replace(/^\uFEFF/,''));
|
||||
const state=read('C:/laragon/data/sgf-tempo/run/modular-regression-current.json');
|
||||
if(state.port!==55439||!/^[a-f0-9]{12}$/.test(state.id))throw Error('Wrong isolated host');
|
||||
const resume=process.argv[2];if(resume&&!/^[a-f0-9]{12}$/.test(resume))throw Error('Invalid QA resume tag');
|
||||
const tag=resume??crypto.randomBytes(6).toString('hex'),root=path.join(state.root,'private/payable-history-'+tag);
|
||||
fs.mkdirSync(root,{recursive:true});
|
||||
const admin=fs.readFileSync(path.join(state.root,'private/admin-password.txt'),'utf8').trim();
|
||||
const pg=input=>{const r=spawnSync('C:/Program Files/PostgreSQL/17/bin/psql.exe',['-h','127.0.0.1','-p','55439','-U','sgf_regression_owner','-d','postgres','-Atq','-v','ON_ERROR_STOP=1'],{input,encoding:'utf8',windowsHide:true,env:{...process.env,PGPASSWORD:admin}});if(r.status!==0)throw Error('Isolated database provisioning refused');return r.stdout.trim();};
|
||||
if(pg("SELECT current_setting('cluster_name')")!=='sgf_modular_regression_'+state.id)throw Error('Wrong cluster');
|
||||
let config={tag,root,cluster:'sgf_modular_regression_'+state.id,source_instance:'qa-history-'+tag};
|
||||
for(const mode of resume?[]:['native','sgf']){
|
||||
const role='payable_history_'+mode+'_'+tag,database=(mode==='native'?'compras_receipts_test_':'payable_sgf_')+tag,password=crypto.randomBytes(32).toString('hex');
|
||||
pg(`CREATE ROLE ${role} LOGIN PASSWORD '${password}' NOSUPERUSER NOCREATEDB NOCREATEROLE NOREPLICATION NOBYPASSRLS; CREATE DATABASE ${database} OWNER ${role}; REVOKE ALL ON DATABASE ${database} FROM PUBLIC;`);
|
||||
const storage=path.join(root,mode);for(const d of ['framework/cache/data','framework/views','framework/sessions','logs','cache'])fs.mkdirSync(path.join(storage,d),{recursive:true});
|
||||
config[mode]={root:path.resolve(mode==='native'?'../compras':'.'),database,role,password,storage};
|
||||
}
|
||||
const settings=path.join(root,'settings.json');if(resume){config=read(settings);if(config.root!==root||config.tag!==tag||config.cluster!=='sgf_modular_regression_'+state.id)throw Error('Resume context refused');}else fs.writeFileSync(settings,JSON.stringify(config,null,2));
|
||||
const php='C:/laragon/tools/sgf-tempo/php-8.4.27/php.exe';
|
||||
const phases=process.argv[3]?process.argv[3].split(','):['native','sgf','regression','native-post'];
|
||||
for(const phase of phases){
|
||||
const expected={native:'source.json',sgf:'fixture.json',regression:'regression.json','native-post':'native-post.json',post:'sgf-post.json'}[phase];if(!expected)throw Error('Unknown phase');const started=Date.now();
|
||||
if(fs.existsSync(path.join(root,expected))){const prior=fs.readFileSync(path.join(root,expected)),archive=path.join(root,expected+'.'+crypto.createHash('sha256').update(prior).digest('hex')+'.archive');if(!fs.existsSync(archive))fs.writeFileSync(archive,prior,{flag:'wx'});}
|
||||
const r=spawnSync(php,['scripts/payable-history-sgf-proof.php',settings,phase],{cwd:process.cwd(),encoding:'utf8',windowsHide:true,maxBuffer:8*1024*1024});
|
||||
fs.writeFileSync(path.join(root,phase+'-'+started+'-stdout.txt'),r.stdout||'');fs.writeFileSync(path.join(root,phase+'-'+started+'-stderr.txt'),r.stderr||'');
|
||||
if(r.status!==0||!fs.existsSync(path.join(root,expected))||fs.statSync(path.join(root,expected)).mtimeMs<started-2000)throw Error('QA phase '+phase+' failed; diagnostics retained privately at '+root);
|
||||
process.stdout.write(r.stdout);
|
||||
}
|
||||
fs.writeFileSync(path.join(state.root,'payable-history-latest.json'),JSON.stringify({tag,root},null,2));
|
||||
console.log(JSON.stringify({tag,root,synthetic_only:true,databases_retained:true,installed_or_demo_databases_touched:false}));
|
||||
@@ -0,0 +1,21 @@
|
||||
<?php
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use App\Shared\Context\CompanyContext;
|
||||
use App\Modules\Purchasing\SupplierPayables;
|
||||
use Tempo\Finance\Payables\Payables;
|
||||
|
||||
$f=json_decode(file_get_contents($s['root'].'/fixture.json'),true,64,JSON_THROW_ON_ERROR);$scope=$f['scope'];$strategy=$argv[3]??'';
|
||||
if(!in_array($strategy,['legacy','page25','page100'],true))throw new LogicException('Unknown isolated read strategy');
|
||||
config(['supplier_payable_history.synthetic_enabled'=>true]);$u=App\Models\User::findOrFail($f['actors']['read']);auth()->setUser($u);app(CompanyContext::class)->selectFor($u->id,$scope['tenant_id'],$scope['company_id'],$scope['unit_id']);$service=app(SupplierPayables::class);
|
||||
$volume=DB::table('pa_imported_sources')->where($scope)->count();if(!in_array($volume,[51,1000,5000,10000],true))throw new LogicException('Unknown owned benchmark volume');
|
||||
// Equal bounded warm-up in every independent PHP worker; no cache flush or server change.
|
||||
$service->listing($u,['q'=>'FT-PG-1']);$queries=0;$capture=false;DB::listen(function($q)use(&$queries,&$capture){if($capture)$queries++;});gc_collect_cycles();memory_reset_peak_usage();$base=memory_get_usage(true);$capture=true;$time=hrtime(true);
|
||||
if($strategy==='legacy'){$result=$service->engine($u)->listing(DB::table('pa_imported_sources')->where($scope)->pluck('id')->map(fn($id)=>(int)$id)->all());}
|
||||
else {$result=$service->listing($u,['per_page'=>$strategy==='page25'?25:100]);}
|
||||
$elapsed=(hrtime(true)-$time)/1e6;$capture=false;$peak=memory_get_peak_usage(true);
|
||||
$fingerprints=[];
|
||||
foreach($strategy==='legacy'?[25,100]:[$strategy==='page25'?25:100] as $size){
|
||||
$locals=DB::table('pa_imported_sources')->where($scope)->orderByDesc('id')->limit($size)->pluck('id')->all();$sourceIds=DB::table('pa_sources')->where($scope)->whereIn('local_source_id',$locals)->select('id');$wanted=DB::table('pa_obligations')->where($scope)->whereIn('source_id',$sourceIds)->pluck('id')->all();$set=array_fill_keys($wanted,true);$rows=array_values(array_filter($result['obligations'],fn($o)=>isset($set[$o['id']])));usort($rows,fn($a,$b)=>$a['id']<=>$b['id']);$fingerprints[$size]=hash('sha256',Payables::canonical($rows));
|
||||
}
|
||||
$report=['volume'=>$volume,'strategy'=>$strategy,'time_ms'=>round($elapsed,3),'queries'=>$queries,'php_heap_baseline_bytes'=>$base,'php_heap_peak_bytes'=>$peak,'php_heap_extra_peak_bytes'=>$peak-$base,'rows'=>['documents'=>count($result['documents']),'obligations'=>count($result['obligations']),'proposals'=>$result['proposals']->count(),'events'=>$result['events']->count()],'position_fingerprints'=>$fingerprints,'php'=>PHP_VERSION,'memory_limit'=>ini_get('memory_limit'),'independent_php_process'=>true,'warmup'=>'Same bounded source search in each worker; seeded PostgreSQL caches; no cache flush.','indices_added'=>false,'not_deployment_capacity'=>true];
|
||||
$output=$s['root'].'/bench-'.$volume.'-'.$strategy.'.json';if(is_file($output)){copy($output,$output.'.'.hash_file('sha256',$output).'.archive');}file_put_contents($output,json_encode($report,JSON_PRETTY_PRINT|JSON_THROW_ON_ERROR));echo json_encode($report)."\n";
|
||||
@@ -0,0 +1,45 @@
|
||||
<?php
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Tempo\Finance\Payables\{Payables,HistoricalPayments,PaymentDecisions,PaymentDecisionSchema};
|
||||
use App\Modules\Purchasing\{SupplierPayables,SupplierHistoryReceipts};
|
||||
use App\Shared\Context\CompanyContext;
|
||||
|
||||
$f=json_decode(file_get_contents($s['root'].'/fixture.json'),true,64,JSON_THROW_ON_ERROR);$scope=$f['scope'];$service=app(SupplierPayables::class);config(['supplier_payable_history.synthetic_enabled'=>true]);$u=App\Models\User::findOrFail($f['actors']['read']);auth()->setUser($u);app(CompanyContext::class)->selectFor($u->id,$scope['tenant_id'],$scope['company_id'],$scope['unit_id']);
|
||||
$checks=0;$cases=[];$capture=false;$queries=[];$readState=[];
|
||||
DB::listen(function($event)use(&$capture,&$queries,&$readState){if(!$capture)return;$queries[]=$event->sql;if(!$readState&&str_contains($event->sql,'"pa_imported_sources"')){$pdo=$event->connection->getPdo();$readState=['isolation'=>$pdo->query('SHOW transaction_isolation')->fetchColumn(),'read_only'=>$pdo->query('SHOW transaction_read_only')->fetchColumn()];}});
|
||||
$assert=function(bool $value,string $why)use(&$checks){$checks++;if(!$value)throw new RuntimeException($why);};
|
||||
$case=function(string $name,callable $test)use(&$cases,&$checks){$before=$checks;DB::beginTransaction();try{$test();$cases[]=['name'=>$name,'assertions'=>$checks-$before,'passed'=>true];}finally{while(DB::transactionLevel()>0)DB::rollBack();}echo 'Passed page case: '.$name."\n";};
|
||||
$positions=function(array $d):string{$rows=$d['obligations'];usort($rows,fn($a,$b)=>$a['id']<=>$b['id']);return hash('sha256',Payables::canonical($rows));};
|
||||
$case('stable-source-window-and-independent-evidence-pages',function()use($assert,$service,$u,$scope){
|
||||
$first=$service->listing($u);$second=$service->listing($u,['page'=>2]);$third=$service->listing($u,['page'=>3]);$assert(count($first['documents'])===25&&count($second['documents'])===25&&count($third['documents'])===1,'25/25/1 source pages');$assert($first['pagination']['documents']['total']===51,'Scoped total');$assert(!array_intersect(array_column($first['documents'],'local_id'),array_column($second['documents'],'local_id')),'Disjoint ordered pages');$assert($first['documents'][24]['local_id']>$second['documents'][0]['local_id'],'Stable descending document order');
|
||||
$source=DB::table('pa_sources')->where($scope)->where('local_source_id',$first['documents'][0]['local_id'])->value('id');$row=(array)DB::table('pa_proposals')->where('source_id',$source)->first();unset($row['id']);for($n=2;$n<=61;$n++)DB::table('pa_proposals')->insert(array_replace($row,['capture_version'=>$n]));
|
||||
$a=$service->listing($u);$b=$service->listing($u,['proposal_page'=>2,'event_page'=>2]);$assert($a['pagination']['proposals']['total']===85,'All source versions retained behind pager');$assert(!array_intersect($a['proposals']->pluck('id')->all(),$b['proposals']->pluck('id')->all()),'Disjoint proposal pages');$assert(!array_intersect($a['events']->pluck('id')->all(),$b['events']->pluck('id')->all()),'Disjoint event pages');$assert(count($a['documents'])===count($b['documents']),'Evidence pager does not change document page');
|
||||
});
|
||||
$case('filters-before-pagination-and-literal-search',function()use($assert,$service,$u){
|
||||
$a=$service->listing($u,['q'=>'Synthetic supplier Alpha']);$assert($a['pagination']['documents']['total']===25,'Supplier filter applies before count/page');$b=$service->listing($u,['q'=>'BENCH-','per_page'=>100]);$assert(count($b['documents'])===50,'Number search');$assert($service->listing($u,['q'=>'%'])['pagination']['documents']['total']===0,'Percent is literal');$assert($service->listing($u,['q'=>'_'])['pagination']['documents']['total']===0,'Underscore is literal');$assert($service->listing($u,['recognition'=>'recognised'])['pagination']['documents']['total']===51,'Recognition filter');$assert($service->listing($u,['recognition'=>'unrecognised'])['pagination']['documents']['total']===0,'Unrecognised filter');$empty=$service->listing($u,['page'=>99]);$assert(!$empty['documents']&&!$empty['obligations']&&$empty['proposals']->isEmpty()&&$empty['events']->isEmpty(),'Out-of-range is empty, not whole book');
|
||||
});
|
||||
$case('full-position-parity-with-original-reader',function()use($assert,$positions,$service,$u,$scope){
|
||||
$ids=DB::table('pa_imported_sources')->where($scope)->orderByDesc('id')->pluck('id')->map(fn($id)=>(int)$id)->all();$old=$service->engine($u)->listing($ids);$new=$service->listing($u,['per_page'=>100]);$assert($positions($old)===$positions($new),'All fields of all financial positions match');$assert(count($old['documents'])===count($new['documents']),'Documents unchanged');$assert($old['proposals']->count()===$new['proposals']->count(),'Proposal retrieval parity within page');
|
||||
});
|
||||
$case('batch-queries-do-not-grow-with-the-page',function()use($assert,$service,$u,&$capture,&$queries,&$readState){
|
||||
$queries=[];$readState=[];$capture=true;$service->listing($u);$capture=false;$small=count($queries);$assert(!array_filter($queries,fn($q)=>str_contains(strtolower($q),'for update')),'Read page takes no writer locks');
|
||||
$queries=[];$readState=[];$capture=true;$large=$service->listing($u,['per_page'=>100]);$capture=false;$assert(count($large['documents'])===51,'Larger valid window');$assert(count($queries)===$small,'Constant SQL count for 25 versus 51 documents');$assert(count(array_filter($queries,fn($q)=>str_contains($q,'from "pa_source_histories"')))===1,'One batch historical-source read');$assert(count(array_filter($queries,fn($q)=>str_contains($q,'from "pa_imported_payments"')))===1,'One batch imported-payments read');
|
||||
});
|
||||
$case('tenant-company-unit-isolation-and-access-revocation',function()use($assert,$service,$u,$scope,$f){
|
||||
$doc=$service->document($u,$f['source_id']);unset($doc['local_id']);$doc['number']='FOREIGN-SECRET';$foreign=DB::table('pa_imported_sources')->insertGetId(['tenant_id'=>$scope['tenant_id']+100,'company_id'=>$scope['company_id']+100,'unit_id'=>$scope['unit_id']+100,'payload'=>Payables::canonical($doc),'receipt_hash'=>hash('sha256',Payables::canonical($doc))]);$assert($service->listing($u,['q'=>'FOREIGN-SECRET'])['pagination']['documents']['total']===0,'Foreign source excluded before count');
|
||||
try{$service->document($u,$foreign);throw new RuntimeException('Foreign read accepted');}catch(Symfony\Component\HttpKernel\Exception\HttpExceptionInterface $e){$assert($e->getStatusCode()===403,'Foreign source refused');}
|
||||
DB::table('pa_access')->where($scope)->where('user_id',$u->id)->delete();try{$service->listing($u);throw new RuntimeException('Revoked read accepted');}catch(Symfony\Component\HttpKernel\Exception\HttpExceptionInterface $e){$assert($e->getStatusCode()===403,'Current read right revalidated');}
|
||||
});
|
||||
$case('receipt-changes-do-not-reuse-a-previous-page-snapshot',function()use($assert,$service,$u,$scope,$positions){
|
||||
$page=$service->listing($u);$id=$page['documents'][0]['local_id'];$row=DB::table('pa_source_histories')->where($scope)->where('local_source_id',$id)->first();$p=json_decode($row->payload,true,64,JSON_THROW_ON_ERROR);$p['receipt_version']=2;DB::table('pa_source_histories')->insert($scope+['local_source_id'=>$id,'version'=>2,'payload'=>Payables::canonical($p),'receipt_hash'=>str_repeat('0',64),'created_at'=>now()]);$next=$service->listing($u);$bad=array_filter($next['obligations'],fn($o)=>$o['source_conflict']);$assert(count($bad)===1&&array_values($bad)[0]['balance_minor']===null,'Only changed receipt becomes indeterminate');$assert(count($next['documents'])===25,'Other documents remain readable');$all=DB::table('pa_imported_sources')->where($scope)->pluck('id')->map(fn($v)=>(int)$v)->all();$legacy=$service->engine($u)->listing($all);$ids=array_column($next['obligations'],'id');$legacy['obligations']=array_values(array_filter($legacy['obligations'],fn($o)=>in_array($o['id'],$ids,true)));$assert($positions($legacy)===$positions($next),'Refusals/indeterminate state identical to original reader');
|
||||
});
|
||||
$case('allocation-reversal-aggregation-is-equivalent',function()use($assert,$service,$u,$scope,$positions){
|
||||
foreach(['ps_allocations','ps_reversals'] as $table)DB::statement("CREATE TABLE $table(tenant_id bigint,company_id bigint,unit_id bigint,obligation_id bigint,amount_minor bigint)");$page=$service->listing($u,['per_page'=>100]);$ob=$page['obligations'][0]['id'];DB::table('ps_allocations')->insert([$scope+['obligation_id'=>$ob,'amount_minor'=>500],$scope+['obligation_id'=>$ob,'amount_minor'=>200]]);DB::table('ps_reversals')->insert($scope+['obligation_id'=>$ob,'amount_minor'=>500]);$ids=DB::table('pa_imported_sources')->where($scope)->pluck('id')->map(fn($v)=>(int)$v)->all();$old=$service->engine($u)->listing($ids);$new=$service->listing($u,['per_page'=>100]);$assert($positions($old)===$positions($new),'Allocation/reversal positions match');$o=array_values(array_filter($new['obligations'],fn($r)=>$r['id']===$ob))[0];$assert($o['allocated_payments_minor']===200&&$o['balance_minor']===2800,'Net allocation still belongs to existing balance formula');
|
||||
});
|
||||
$case('historical-decision-chain-is-validated-in-both-readers',function()use($assert,$service,$u,$scope,$positions){
|
||||
PaymentDecisionSchema::install();$page=$service->listing($u);$doc=$page['documents'][0];unset($doc['revision'],$doc['missing']);$origin=DB::table('pa_sources')->where($scope)->where('local_source_id',$doc['local_id'])->first();$import=DB::table('pa_history_imports')->where($scope)->where('source_id',$origin->id)->first();$payments=json_decode($import->snapshot,true,64,JSON_THROW_ON_ERROR)['payments'];$pending=null;foreach($payments as &$p)if($p['status']==='pending'){$pending=$p['id'];$p['status']='confirmed';$p['reviewed_by']=900000;$p['reviewed_at']='2026-11-02 12:00:00';$p['decision_audit_id']=900001;}unset($p);$capture=HistoricalPayments::capture($doc,$payments);(new PaymentDecisions(DB::connection(),$scope))->record($import,$capture,$pending,900000);
|
||||
$payload=['version'=>SupplierHistoryReceipts::VERSION,'receipt_version'=>2,'source_key'=>Payables::identity($doc),'source_revision'=>Payables::revision($doc),'history_hash'=>$capture['history_hash'],'payments'=>$capture['payments']];DB::table('pa_source_histories')->insert($scope+['local_source_id'=>$doc['local_id'],'version'=>2,'payload'=>Payables::canonical($payload),'receipt_hash'=>hash('sha256',Payables::canonical($payload)),'created_at'=>now()]);$ids=DB::table('pa_imported_sources')->where($scope)->pluck('id')->map(fn($v)=>(int)$v)->all();$old=$service->engine($u)->listing($ids);$new=$service->listing($u,['per_page'=>100]);$assert($positions($old)===$positions($new),'Complete decision-chain parity');$o=array_values(array_filter($new['obligations'],fn($r)=>$r['source_id']===$origin->id))[0];$assert($o['confirmed_payments_minor']===4000&&$o['pending_payments_minor']===0&&$o['balance_minor']===2000,'Existing decision-chain effect remains exact');
|
||||
});
|
||||
// This final read starts outside any write transaction, as the real HTTP endpoint does.
|
||||
$queries=[];$readState=[];$capture=true;$service->listing($u);$capture=false;$assert($readState===['isolation'=>'repeatable read','read_only'=>'on'],'Coherent read-only HTTP-style transaction');
|
||||
$report=['kind'=>'financial_page_regression','cases'=>$cases,'scenarios'=>count($cases),'assertions'=>$checks,'read_transaction'=>$readState,'last_read_queries'=>count($queries),'benchmark_fixture_documents'=>51,'no_new_decision_capability'=>true,'decision_and_allocation_rows_rollback_only'=>true];file_put_contents($s['root'].'/page-cases.json',json_encode($report,JSON_PRETTY_PRINT|JSON_THROW_ON_ERROR));echo json_encode(['scenarios'=>count($cases),'assertions'=>$checks,'passed'=>true])."\n";
|
||||
@@ -0,0 +1,28 @@
|
||||
<?php
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Tempo\Finance\Payables\{Payables,HistoricalPayments};
|
||||
|
||||
// Benchmark clones only, explicitly different from the one native conferred document.
|
||||
$f=json_decode(file_get_contents($s['root'].'/fixture.json'),true,64,JSON_THROW_ON_ERROR);$scope=$f['scope'];
|
||||
$target=(int)($argv[3]??0);if(!in_array($target,[51,251,1000,5000,10000],true))throw new LogicException('Bounded QA volume required');
|
||||
$count=DB::table('pa_imported_sources')->where($scope)->count();if($count>$target)throw new LogicException('Retained QA data is never reset');
|
||||
$bundle=json_decode(file_get_contents($s['root'].'/source.json'),true,64,JSON_THROW_ON_ERROR);$base=$bundle['source'];unset($base['local_id']);$now=now();
|
||||
for($start=$count;$start<$target;$start+=100){
|
||||
$size=min(100,$target-$start);$ids=DB::select("SELECT nextval(pg_get_serial_sequence('pa_imported_sources','id')) AS local_id,nextval(pg_get_serial_sequence('pa_sources','id')) AS source_id,nextval(pg_get_serial_sequence('pa_proposals','id')) AS proposal_id,nextval(pg_get_serial_sequence('pa_obligations','id')) AS obligation_id FROM generate_series(1,?)",[$size]);
|
||||
$rows=array_fill_keys(['pa_imported_sources','pa_source_histories','pa_sources','pa_proposals','pa_obligations','pa_history_imports','pa_imported_payments','pa_events'],[]);
|
||||
foreach($ids as $offset=>$id){
|
||||
$n=$start+$offset+1;$doc=array_replace($base,['document'=>100000+$n,'number'=>'BENCH-'.str_pad((string)$n,5,'0',STR_PAD_LEFT),'supplier_name'=>$n%2===0?'Synthetic supplier Alpha':'Synthetic supplier Beta']);$local=(int)$id->local_id;$source=(int)$id->source_id;$proposal=(int)$id->proposal_id;$obligation=(int)$id->obligation_id;
|
||||
$payments=$bundle['capture']['payments'];foreach($payments as $i=>&$payment){$payment['id']=1000000+$n*3+$i;$payment['document']=$doc['document'];}unset($payment);$capture=HistoricalPayments::capture($doc,$payments);$key=Payables::identity($doc);$revision=Payables::revision($doc);
|
||||
$receipt=['version'=>App\Modules\Purchasing\SupplierHistoryReceipts::VERSION,'receipt_version'=>1,'source_key'=>$key,'source_revision'=>$revision,'history_hash'=>$capture['history_hash'],'payments'=>$capture['payments']];
|
||||
$rows['pa_imported_sources'][]=$scope+['id'=>$local,'payload'=>Payables::canonical($doc),'receipt_hash'=>hash('sha256',Payables::canonical($doc))];
|
||||
$rows['pa_source_histories'][]=$scope+['local_source_id'=>$local,'version'=>1,'payload'=>Payables::canonical($receipt),'receipt_hash'=>hash('sha256',Payables::canonical($receipt)),'created_at'=>$now];
|
||||
$rows['pa_sources'][]=$scope+['id'=>$source,'source_key'=>$key,'local_source_id'=>$local,'created_at'=>$now];
|
||||
$rows['pa_proposals'][]=$scope+['id'=>$proposal,'source_id'=>$source,'source_revision'=>$revision,'capture_version'=>1,'snapshot'=>Payables::canonical($doc+['local_id'=>$local,'_history_import'=>$capture]),'missing'=>'[]','prepared_by'=>$f['actors']['prepare'],'prepared_person'=>'sgf-history-person-'.$f['actors']['prepare'],'reason'=>'Synthetic benchmark clone, not another native workflow execution.','created_at'=>$now];
|
||||
$rows['pa_obligations'][]=$scope+['id'=>$obligation,'source_id'=>$source,'proposal_id'=>$proposal,'source_revision'=>$revision,'principal_minor'=>6000,'currency'=>'MZN','due_on'=>null,'recognised_by'=>$f['actors']['validate'],'recognised_person'=>'sgf-history-person-'.$f['actors']['validate'],'reason'=>'Synthetic benchmark recognised fixture.','created_at'=>$now];
|
||||
$rows['pa_history_imports'][]=$scope+['obligation_id'=>$obligation,'source_id'=>$source,'source_key'=>$key,'history_hash'=>$capture['history_hash'],'confirmed_minor'=>3000,'pending_minor'=>1000,'rejected_minor'=>500,'snapshot'=>Payables::canonical($capture),'imported_by'=>$f['actors']['validate'],'created_at'=>$now];
|
||||
foreach($capture['payments'] as $p)$rows['pa_imported_payments'][]=$scope+['obligation_id'=>$obligation,'source_id'=>$source,'payment_key'=>$p['payment_key'],'legacy_payment_id'=>$p['id'],'amount_minor'=>$p['amount_minor'],'status'=>$p['status'],'snapshot'=>Payables::canonical($p),'created_at'=>$now];
|
||||
foreach(['payable.proposed','payable.legacy_history_imported','payable.recognised'] as $kind)$rows['pa_events'][]=$scope+['source_id'=>$source,'actor_id'=>$f['actors']['validate'],'kind'=>$kind,'payload'=>Payables::canonical(['benchmark_only'=>true,'proposal_id'=>$proposal,'obligation_id'=>$obligation]),'created_at'=>$now];
|
||||
}
|
||||
DB::transaction(function()use($rows){foreach($rows as $table=>$batch)DB::table($table)->insert($batch);});
|
||||
}
|
||||
file_put_contents($s['root'].'/page-volume.json',json_encode(['documents'=>$target,'benchmark_clones'=>$target-1,'native_workflow_documents'=>1,'chunk_size'=>100,'indices_added'=>false],JSON_PRETTY_PRINT));echo 'Owned synthetic page fixture volume '.$target." ready.\n";
|
||||
@@ -0,0 +1,29 @@
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import net from 'node:net';
|
||||
import {spawn} from 'node:child_process';
|
||||
import assert from 'node:assert/strict';
|
||||
const state=JSON.parse(fs.readFileSync('C:/laragon/data/sgf-tempo/run/modular-regression-current.json','utf8')),tag=process.argv[2];
|
||||
if(state.port!==55439||!/^[a-f0-9]{12}$/.test(tag||''))throw Error('Owned QA tag required');
|
||||
const root=path.join(state.root,'private/payable-history-'+tag),settings=path.join(root,'settings.json'),config=JSON.parse(fs.readFileSync(settings,'utf8'));
|
||||
if(config.tag!==tag||config.cluster!=='sgf_modular_regression_'+state.id)throw Error('Wrong owned host');
|
||||
await new Promise((resolve,reject)=>{const probe=net.createServer();probe.once('error',()=>reject(Error('Loopback proof port occupied')));probe.listen(8047,'127.0.0.1',()=>probe.close(resolve));});
|
||||
const log=fs.openSync(path.join(root,'page-http-server.log'),'a'),child=spawn('C:/laragon/tools/sgf-tempo/php-8.4.27/php.exe',['-S','127.0.0.1:8047',path.resolve('scripts/payable-history-sgf-http-router.php')],{cwd:process.cwd(),env:{...process.env,PAYABLE_HISTORY_QA_SETTINGS:settings},windowsHide:true,stdio:['ignore',log,log]});
|
||||
let checks=0;const check=(v,why)=>{checks++;assert(v,why);};
|
||||
const client=()=>{const jar=new Map();let auth;const request=async(url,{body,headers:extra={}}={})=>{const headers={Accept:'application/json','X-SGF-Context':auth?.context??'',Cookie:[...jar].map(([k,v])=>k+'='+v).join('; '),...extra};if(body){headers['Content-Type']='application/json';headers['X-CSRF-TOKEN']=auth?.csrf??'';headers['Idempotency-Key']=crypto.randomUUID();}const r=await fetch('http://127.0.0.1:8047'+url,{method:body?'POST':'GET',headers,body:body?JSON.stringify(body):undefined,redirect:'manual',signal:AbortSignal.timeout(8000)});for(const c of r.headers.getSetCookie()){const pair=c.split(';')[0],at=pair.indexOf('=');jar.set(pair.slice(0,at),pair.slice(at+1));}const text=await r.text();let data;try{data=JSON.parse(text);}catch{}return {status:r.status,data,text,headers:r.headers};};return {request,enter:async role=>{const r=await request('/qa/enter/'+role);if(r.status!==200)throw Error('QA entry failed');auth=r.data;return auth;}};};
|
||||
try {
|
||||
const reader=client();let ready=false;for(let n=0;n<50&&!ready;n++){try{await reader.enter('read');ready=true;}catch{await new Promise(r=>setTimeout(r,100));}}check(ready,'Owned host ready');
|
||||
const first=await reader.request('/api/supplier-payables');check(first.status===200,'Page endpoint available');const a=first.data,volume=a.pagination.documents.total;check(volume>=1000&&a.documents.length===25&&a.obligations.length===25,'Default source and position window bounded');check(a.proposals.length===25&&a.events.length===25,'Evidence windows bounded');
|
||||
const second=await reader.request('/api/supplier-payables?page=2');check(second.status===200&&second.data.documents.length===25,'Second source page');const keys=new Set(a.documents.map(d=>d.local_id));check(!second.data.documents.some(d=>keys.has(d.local_id)),'Disjoint pages');check(a.documents.at(-1).local_id>second.data.documents[0].local_id,'Stable descending order');
|
||||
const large=await reader.request('/api/supplier-payables?per_page=100');check(large.status===200&&large.data.documents.length===100&&large.data.events.length===100,'100 row server page');
|
||||
const events=await reader.request('/api/supplier-payables?event_page=2');check(events.status===200&&!events.data.events.some(e=>a.events.some(old=>old.id===e.id)),'Independent disjoint event window');check(events.data.documents[0].local_id===a.documents[0].local_id,'Event pager preserves source window');
|
||||
const one=await reader.request('/api/supplier-payables?q=BENCH-00002');check(one.status===200&&one.data.pagination.documents.total===1&&one.data.documents.length===1,'Number filter before pagination');
|
||||
const missing=await reader.request('/api/supplier-payables?recognition=unrecognised');check(missing.status===200&&missing.data.pagination.documents.total===0&&missing.data.events.length===0,'Recognition filter does not expose other records');
|
||||
for(const query of ['page=0','per_page=1000','event_page=0','recognition=paid','company_id=999','q%5Ba%5D=x'])check((await reader.request('/api/supplier-payables?'+query)).status===422,'Unsupported/invalid filters refused');
|
||||
check((await reader.request('/api/supplier-payables',{headers:{'X-SGF-Context':'obsolete'}})).status===409,'Stale context refused on reads');
|
||||
check((await reader.request('/api/supplier-payables/prepare_history',{body:{id:a.documents[0].local_id,reason:'Reader must not acquire write rights from a page.'}})).status===403,'Read page grants no write access');
|
||||
const html=await reader.request('/supplier-payables?q=BENCH&recognition=recognised&per_page=25');check(html.status===200&&html.text.includes('Filtrar documentos')&&html.text.includes('Paginação documents')&&html.text.includes('Paginação events'),'Existing HTML has server filters and pagers');check(html.text.includes('q=BENCH&recognition=recognised'),'Pager preserves search/recognition');
|
||||
const injection=await reader.request('/supplier-payables?q='+encodeURIComponent('<script>page_injection</script>'));check(injection.status===200&&!injection.text.includes('<script>page_injection</script>')&&injection.text.includes('<script>page_injection</script>'),'Search text escaped in HTML');
|
||||
check(first.headers.get('cache-control').includes('no-store'),'Private response');
|
||||
const report={kind:'financial_pagination_actual_http',checks,passed:true,volume,default_page:25,larger_page:100,filter_before_count:true,source_and_evidence_pages_disjoint:true,context_and_rights_enforced:true,unknown_parameters_refused:true,html_escaping:true,visual_review:false,browser_interactions_tested:false};fs.writeFileSync(path.join(root,'page-http.json'),JSON.stringify(report,null,2)+'\n');console.log(JSON.stringify(report));
|
||||
} finally {if(child.exitCode===null){child.kill();await new Promise(resolve=>child.once('exit',resolve));}fs.closeSync(log);}
|
||||
@@ -0,0 +1,22 @@
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import {spawnSync} from 'node:child_process';
|
||||
const state=JSON.parse(fs.readFileSync('C:/laragon/data/sgf-tempo/run/modular-regression-current.json','utf8')),tag=process.argv[2];
|
||||
if(state.port!==55439||!/^[a-f0-9]{12}$/.test(tag||''))throw Error('Owned QA tag required');
|
||||
const root=path.join(state.root,'private/payable-history-'+tag),settings=path.join(root,'settings.json');
|
||||
const config=JSON.parse(fs.readFileSync(settings,'utf8'));if(config.tag!==tag||config.cluster!=='sgf_modular_regression_'+state.id)throw Error('Owned QA identity mismatch');
|
||||
const volumes=process.argv[3]?process.argv[3].split(',').map(Number):[1000,5000,10000];
|
||||
for(const volume of volumes){
|
||||
if(![51,1000,5000,10000].includes(volume))throw Error('Unsupported QA volume');
|
||||
const calls=[['page-seed',String(volume)],...['legacy','page25','page100'].map(strategy=>['page-bench',strategy])];
|
||||
for(const [phase,value] of calls){
|
||||
const started=Date.now();const r=spawnSync('C:/laragon/tools/sgf-tempo/php-8.4.27/php.exe',['-d','memory_limit=512M','scripts/payable-history-sgf-proof.php',settings,phase,value],{encoding:'utf8',windowsHide:true,maxBuffer:1024*1024});
|
||||
fs.writeFileSync(path.join(root,phase+'-'+volume+'-'+value+'-'+started+'.log'),(r.stdout||'')+(r.stderr||''));
|
||||
const expected=path.join(root,phase==='page-seed'?'page-volume.json':`bench-${volume}-${value}.json`);
|
||||
if(r.status!==0||!fs.existsSync(expected)||fs.statSync(expected).mtimeMs<started-2000)throw Error('Owned QA load phase failed; private diagnostics at '+root);
|
||||
process.stdout.write(r.stdout);
|
||||
}
|
||||
const read=strategy=>JSON.parse(fs.readFileSync(path.join(root,`bench-${volume}-${strategy}.json`),'utf8'));
|
||||
const legacy=read('legacy');for(const size of [25,100]){const current=read('page'+size);if(legacy.position_fingerprints[size]!==current.position_fingerprints[size])throw Error('Financial position parity differs at volume '+volume);}
|
||||
console.log(JSON.stringify({volume,financial_position_parity:true,indices_added:false}));
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import crypto from 'node:crypto';
|
||||
import {spawnSync} from 'node:child_process';
|
||||
const read=p=>JSON.parse(fs.readFileSync(p,'utf8'));
|
||||
const state=read('C:/laragon/data/sgf-tempo/run/modular-regression-current.json');
|
||||
if(state.port!==55439||!/^[a-f0-9]{12}$/.test(state.id))throw Error('Wrong QA host');
|
||||
const sourceTag=process.argv[2];if(!/^[a-f0-9]{12}$/.test(sourceTag||''))throw Error('Original historical QA source tag required');
|
||||
const original=path.join(state.root,'private/payable-history-'+sourceTag),bundle=read(path.join(original,'source.json'));
|
||||
const tag=crypto.randomBytes(6).toString('hex'),root=path.join(state.root,'private/payable-history-'+tag),role='payable_history_sgf_'+tag,database='payable_sgf_'+tag;
|
||||
const password=crypto.randomBytes(32).toString('hex'),admin=fs.readFileSync(path.join(state.root,'private/admin-password.txt'),'utf8').trim();
|
||||
const pg=input=>{const r=spawnSync('C:/Program Files/PostgreSQL/17/bin/psql.exe',['-h','127.0.0.1','-p','55439','-U','sgf_regression_owner','-d','postgres','-Atq','-v','ON_ERROR_STOP=1'],{input,encoding:'utf8',windowsHide:true,env:{...process.env,PGPASSWORD:admin}});if(r.status!==0)throw Error('Private QA provisioning refused');return r.stdout.trim();};
|
||||
if(pg("SELECT current_setting('cluster_name')")!=='sgf_modular_regression_'+state.id)throw Error('Wrong effective cluster');
|
||||
pg(`CREATE ROLE ${role} LOGIN PASSWORD '${password}' NOSUPERUSER NOCREATEDB NOCREATEROLE NOREPLICATION NOBYPASSRLS; CREATE DATABASE ${database} OWNER ${role}; REVOKE ALL ON DATABASE ${database} FROM PUBLIC;`);
|
||||
const storage=path.join(root,'sgf');for(const d of ['framework/cache/data','framework/views','framework/sessions','logs','cache'])fs.mkdirSync(path.join(storage,d),{recursive:true});
|
||||
const config={tag,root,cluster:'sgf_modular_regression_'+state.id,source_instance:bundle.source.instance,original_source_tag:sourceTag,sgf:{root:process.cwd(),database,role,password,storage}};
|
||||
fs.writeFileSync(path.join(root,'settings.json'),JSON.stringify(config,null,2));fs.copyFileSync(path.join(original,'source.json'),path.join(root,'source.json'));
|
||||
for(const phase of ['sgf','regression']){const r=spawnSync('C:/laragon/tools/sgf-tempo/php-8.4.27/php.exe',['scripts/payable-history-sgf-proof.php',path.join(root,'settings.json'),phase],{encoding:'utf8',windowsHide:true});fs.writeFileSync(path.join(root,phase+'-initial-stdout.txt'),r.stdout||'');fs.writeFileSync(path.join(root,phase+'-initial-stderr.txt'),r.stderr||'');if(r.status!==0||!fs.existsSync(path.join(root,phase==='sgf'?'fixture.json':'regression.json')))throw Error('Private '+phase+' failed at '+root);process.stdout.write(r.stdout);}
|
||||
fs.writeFileSync(path.join(state.root,'payable-page-latest.json'),JSON.stringify({tag,root},null,2));console.log(JSON.stringify({tag,root,source_qa_preserved:true,new_sgf_database_only:true}));
|
||||
@@ -0,0 +1,25 @@
|
||||
<?php
|
||||
// Read-only reconciliation of the authorised persistent synthetic demonstration.
|
||||
$host='C:/laragon/data/sgf-tempo/run/modular-regression-2576427d1939/development-app';
|
||||
require $host.'/vendor/autoload.php';
|
||||
$app=require $host.'/bootstrap/app.php';
|
||||
$app->make(Illuminate\Contracts\Console\Kernel::class)->bootstrap();
|
||||
$db=Illuminate\Support\Facades\DB::connection();
|
||||
$db->beginTransaction();
|
||||
$db->statement('SET TRANSACTION READ ONLY');
|
||||
$identity=$db->selectOne("select current_database() as db,current_user as role,inet_server_port() as port,current_setting('cluster_name') as cluster");
|
||||
if($identity->db!=='sgf_modules_dev_2576427d1939'||$identity->role!=='sgf_modules_qa_2576427d1939'||(int)$identity->port!==55439||$identity->cluster!=='sgf_modular_regression_2576427d1939')throw new RuntimeException('Wrong destination');
|
||||
$recovery=dirname($host).'/private/requisitions-recovery';
|
||||
$baseline=json_decode(file_get_contents($recovery.'/baseline.json'),true,64,JSON_THROW_ON_ERROR);
|
||||
$grants=json_decode(json_encode($db->table('permission_grants')->orderBy('id')->get()),true);
|
||||
$ids=array_column($baseline['grants'],'id');
|
||||
foreach($baseline['grants'] as $row)if(!in_array($row,$grants,true))throw new RuntimeException('Existing grant changed');
|
||||
$new=array_values(array_filter($grants,fn($x)=>!in_array($x['id'],$ids)));
|
||||
$rights=[1=>['purchasing_requisitions.view','purchasing_requisitions.prepare','purchasing_requisitions.configure','purchasing_requisitions.approve.department_head','purchasing_budget.view','purchasing_budget.create','purchasing_budget.approve','purchasing_budget.record'],2=>['purchasing_requisitions.view','purchasing_requisitions.approve.department_head','purchasing_budget.view','purchasing_budget.approve'],3=>['purchasing_requisitions.view','purchasing_budget.view']];
|
||||
if(count($new)!==14)throw new RuntimeException('Expected exactly fourteen new grants');
|
||||
foreach($new as $g)if($g['tenant_id']!==1||$g['company_id']!==1||$g['unit_id']!==1||!in_array($g['permission'],$rights[$g['user_id']]??[],true))throw new RuntimeException('Unexpected grant');
|
||||
$usersUnchanged=hash('sha256',json_encode($db->table('users')->orderBy('id')->get()))===$baseline['users_sha256'];
|
||||
if(!$usersUnchanged)throw new RuntimeException('Users changed');
|
||||
$state=['identity'=>$identity,'read_only'=>true,'previous_grants_preserved'=>true,'new_grants'=>$new,'users_unchanged'=>$usersUnchanged,'backup_valid'=>hash_file('sha256',$recovery.'/before.dump')===$baseline['backup_sha256'],'backup_sha256'=>$baseline['backup_sha256'],'centres'=>$db->table('cb_centres')->get(),'budgets'=>$db->table('cb_budgets')->get(),'requests'=>$db->table('ir_requests')->get(['id','status','requester_id','version','total_minor']),'steps'=>$db->table('ir_steps')->get(),'entries'=>$db->table('cb_budget_entries')->get(),'policy'=>$db->table('ir_policies')->get(),'initialisation_scopes'=>config('purchasing-access.initialisation_scopes'),'operational'=>config('purchasing-operational'),'pointer_unchanged'=>hash_file('sha256',$host.'/packages/purchasing/current.json')===$baseline['pointer_sha256']];
|
||||
$db->rollBack();
|
||||
echo json_encode($state,JSON_PRETTY_PRINT|JSON_THROW_ON_ERROR);
|
||||
@@ -0,0 +1,48 @@
|
||||
import fs from 'node:fs';import path from 'node:path';import crypto from 'node:crypto';
|
||||
import assert from 'node:assert/strict';import React from 'react';import {renderToStaticMarkup} from 'react-dom/server';import {createServer} from 'vite';
|
||||
const candidate=fs.realpathSync(process.argv[2]||'');
|
||||
const app=fs.readFileSync(path.join(candidate,'resources/js/app.jsx'),'utf8');
|
||||
const component=app.slice(app.indexOf('function AccessPanel('),app.indexOf("createRoot(document.getElementById('app'))"));
|
||||
assert(component.startsWith('function AccessPanel('));
|
||||
const work=path.join('C:/laragon/data/sgf-tempo/run','admin-render-'+crypto.randomBytes(6).toString('hex'));
|
||||
fs.mkdirSync(work,{recursive:true});fs.symlinkSync(path.join(process.cwd(),'node_modules'),path.join(work,'node_modules'),'junction');
|
||||
// Fixture-inject only the server response into the real candidate component.
|
||||
// Event handlers, permission filtering, labels and form markup are unchanged.
|
||||
const fixture=component.replace('function AccessPanel({ context, can, api, perform, busy })','export default function AccessPanel({ context, can, api, perform, busy, initialList, initialScope })')
|
||||
.replace('const [list, setList] = useState(null)','const [list, setList] = useState(initialList)')
|
||||
.replace("const [scope, setScope] = useState('company')","const [scope, setScope] = useState(initialScope)");
|
||||
fs.writeFileSync(path.join(work,'AccessPanel.jsx'),"import React,{useState,useEffect} from 'react';\nconst labels={'unit.view':'Consultar unidade','company.view':'Consultar empresa'};\n"+fixture);
|
||||
const server=await createServer({root:work,configFile:false,server:{middlewareMode:true,fs:{allow:[work,process.cwd()]}},appType:'custom'});
|
||||
const checks=[];const check=(value,name)=>{assert(value,name);checks.push(name);};
|
||||
try{
|
||||
const {default:Access}=await server.ssrLoadModule(path.join(work,'AccessPanel.jsx'));
|
||||
const unit=7,context={token:'synthetic',company:{id:3},unit:{id:unit},units:[{id:unit,name:'Unidade sintética'}]};
|
||||
const rights=['unit.view','unit.edit','purchasing_requisitions.view','purchasing_requisitions.prepare','purchasing_requisitions.approve.department_head','purchasing_budget.create','purchasing_budget.approve'];
|
||||
const list={members:[{id:1,name:'Sintético',email:'example@example.test'}],grants:[{id:1,user_id:1,permission:'purchasing_requisitions.prepare',unit_id:unit}],catalogue:{company:['company.view','access.manage'],unit:rights},catalogue_labels:{'purchasing_requisitions.prepare':'Preparar e submeter requisições','purchasing_requisitions.approve.department_head':'Aprovar como responsável de departamento','purchasing_budget.create':'Preparar dotações','purchasing_budget.approve':'Aprovar dotações'}};
|
||||
const render=(granted,initialList=list,initialScope=String(unit))=>renderToStaticMarkup(React.createElement(Access,{context,initialList,initialScope,can:(p,u=null)=>granted.some(g=>g[0]===p&&g[1]===u),api:async()=>{},perform(){},busy:false}));
|
||||
const preparer=render([['unit.view',unit],['purchasing_requisitions.prepare',unit],['purchasing_budget.create',unit]]);
|
||||
check(preparer.includes('value="purchasing_requisitions.prepare"'),'native selector offers server catalogue requisition right');
|
||||
check(preparer.includes('Preparar e submeter requisições'),'native selector uses server labels');
|
||||
check(preparer.includes('Preparar dotações'),'budget creation right has server label');
|
||||
check(!preparer.includes('value="purchasing_budget.approve"'),'preparer cannot delegate budget approval');
|
||||
check(!preparer.includes('value="purchasing_requisitions.approve.department_head"'),'preparer cannot delegate absent decision authority');
|
||||
const reviewer=render([['unit.view',unit],['purchasing_budget.approve',unit]]);
|
||||
check(reviewer.includes('value="purchasing_budget.approve"'),'reviewer sees only own approval grant');
|
||||
check(!reviewer.includes('value="purchasing_budget.create"'),'reviewer cannot delegate preparation');
|
||||
const foreign=render([['purchasing_requisitions.prepare',99]]);
|
||||
check(!foreign.includes('value="purchasing_requisitions.prepare"'),'foreign unit grant never becomes a local option');
|
||||
const reduced=render([['purchasing_requisitions.prepare',unit]],{...list,catalogue:{company:[],unit:['unit.view']}});
|
||||
check(!reduced.includes('value="purchasing_requisitions.prepare"'),'client cannot invent a catalogue entry from a grant');
|
||||
const waiting=render([['purchasing_requisitions.prepare',unit]],null);
|
||||
check(!waiting.includes('value="purchasing_requisitions.prepare"'),'no hardcoded new rights before server catalogue loads');
|
||||
check(preparer.includes('<td>Preparar e submeter requisições</td>'),'existing grants render their server labels');
|
||||
check(app.includes("allGroups.filter(g=>['start','admin'].includes(g.id))"),'general navigation remains separate');
|
||||
const home=fs.readFileSync(path.join(candidate,'resources/js/PurchasingHome.jsx'),'utf8');
|
||||
check(home.includes("can('purchasing_requisitions.view',unit)")&&home.includes('/purchasing/internal-requisitions'),'module entry links to requisition only under own right');
|
||||
const manifest=JSON.parse(fs.readFileSync(path.join(candidate,'public/build/manifest.json'),'utf8'));
|
||||
for(const chunk of Object.values(manifest))for(const file of [chunk.file,...(chunk.css||[])]){
|
||||
check(file.startsWith('modules/internal-requisitions/assets/'),'frontend asset bound to installed bundle');
|
||||
check(fs.existsSync(path.join(candidate,'integrations/sgf/frontend/assets',path.basename(file))),'referenced compiled dependency included');
|
||||
}
|
||||
console.log(JSON.stringify({passed:true,checks,browser_used:false,source_host_modified:false},null,2));
|
||||
}finally{await server.close();}
|
||||
@@ -0,0 +1,41 @@
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import crypto from 'node:crypto';
|
||||
import {spawnSync} from 'node:child_process';
|
||||
const state=JSON.parse(fs.readFileSync('C:/laragon/data/sgf-tempo/run/modular-regression-current.json','utf8'));
|
||||
if(!/^[a-f0-9]{12}$/.test(state.id)||path.resolve(state.root)!==path.resolve('C:/laragon/data/sgf-tempo/run/modular-regression-'+state.id)||state.port!==55439)throw Error('Unexpected regression cluster');
|
||||
const resume=process.argv[2];if(resume&&!/^[a-f0-9]{12}$/.test(resume))throw Error('Unexpected resume identifier');
|
||||
const tag=resume??crypto.randomBytes(6).toString('hex'),root=path.join(state.root,'private','stock-composition-'+tag),app=path.join(root,'app');
|
||||
const source=path.resolve('../compras'),php='C:/laragon/tools/sgf-tempo/php-8.4.27/php.exe',psql='C:/Program Files/PostgreSQL/17/bin/psql.exe';
|
||||
fs.mkdirSync(app,{recursive:true});
|
||||
const password=crypto.randomBytes(32).toString('hex'),database='stock_read_compras_'+tag,role='stock_read_qa_'+tag;
|
||||
const admin=fs.readFileSync(path.join(state.root,'private','admin-password.txt'),'utf8').trim();
|
||||
if(!resume){
|
||||
const identity=spawnSync(psql,['-h','127.0.0.1','-p','55439','-U','sgf_regression_owner','-d','postgres','-At','-v','ON_ERROR_STOP=1'],{
|
||||
encoding:'utf8',windowsHide:true,env:{...process.env,PGPASSWORD:admin},input:"SELECT current_setting('cluster_name');"});
|
||||
if(identity.status!==0||identity.stdout.trim()!=='sgf_modular_regression_'+state.id)throw Error('Refused to provision outside the expected QA cluster');
|
||||
const pg=spawnSync(psql,['-h','127.0.0.1','-p','55439','-U','sgf_regression_owner','-d','postgres','-v','ON_ERROR_STOP=1'],{
|
||||
encoding:'utf8',windowsHide:true,env:{...process.env,PGPASSWORD:admin},input:`CREATE ROLE ${role} LOGIN PASSWORD '${password}' NOSUPERUSER NOCREATEDB NOCREATEROLE NOREPLICATION NOBYPASSRLS;
|
||||
CREATE DATABASE ${database} OWNER ${role};
|
||||
REVOKE ALL ON DATABASE ${database} FROM PUBLIC;
|
||||
`});
|
||||
if(pg.status!==0)throw Error('Isolated native database provisioning failed; reconcile private directory without retrying this fixture');
|
||||
fs.writeFileSync(path.join(root,'state.json'),JSON.stringify({tag,root,app,database,role,port:55439,cluster:'sgf_modular_regression_'+state.id},null,2));
|
||||
}else{
|
||||
const old=JSON.parse(fs.readFileSync(path.join(root,'state.json'),'utf8'));
|
||||
if(old.database!==database||old.role!==role||path.resolve(old.app)!==path.resolve(app))throw Error('Unexpected private resume state');
|
||||
}
|
||||
for(const dir of ['app','bootstrap','config','routes','resources','tests'])fs.cpSync(path.join(source,dir),path.join(app,dir),{recursive:true,filter:p=>!p.includes('bootstrap'+path.sep+'cache')});
|
||||
for(const pkg of ['inventory-read','purchasing','budget-control','payables'])fs.cpSync(path.join(source,'packages',pkg),path.join(app,'packages',pkg),{recursive:true});
|
||||
fs.copyFileSync(path.join(source,'composer.json'),path.join(app,'composer.json'));
|
||||
if(!fs.existsSync(path.join(app,'vendor')))fs.symlinkSync(path.join(source,'vendor'),path.join(app,'vendor'),'junction');
|
||||
for(const dir of ['bootstrap/cache','storage/logs','storage/framework/cache/data','storage/framework/sessions','storage/framework/views'])fs.mkdirSync(path.join(app,dir),{recursive:true});
|
||||
const key='base64:'+crypto.randomBytes(32).toString('base64');
|
||||
if(!resume)fs.writeFileSync(path.join(app,'.env.testing'),`APP_ENV=testing\nAPP_KEY=${key}\nAPP_DEBUG=false\nAPP_URL=http://localhost\nDB_CONNECTION=pgsql\nDB_HOST=127.0.0.1\nDB_PORT=55439\nDB_DATABASE=${database}\nDB_USERNAME=${role}\nDB_PASSWORD=${password}\nDB_URL=\nSESSION_DRIVER=array\nCACHE_STORE=array\nQUEUE_CONNECTION=sync\nBCRYPT_ROUNDS=4\nSGF_ISOLATED_REGRESSION_RUN=${state.id}\nCOMMON_PAYABLES_ENABLED=false\nCOMMON_PAYABLES_HISTORY_ENABLED=false\nPAYABLE_SOURCE_INSTANCE=\n`);
|
||||
const config=path.join(root,'phpunit.xml');
|
||||
fs.writeFileSync(config,`<phpunit bootstrap="${source.replaceAll('\\','/')}/vendor/autoload.php" colors="true"><php><env name="APP_ENV" value="testing"/><env name="APP_BASE_PATH" value="${app.replaceAll('\\','/')}"/></php></phpunit>`);
|
||||
console.log(JSON.stringify({prepared_database:database,restricted_role:role,native_application_copy:true,no_existing_database_migration:true}));
|
||||
const output=path.join(root,'native-results.json');
|
||||
const r=spawnSync(php,[path.join(source,'vendor/phpunit/phpunit/phpunit'),'-c',config,path.join(app,'tests/Feature/CommonStockReadTest.php'),'--log-junit',path.join(root,'native-junit.xml')],{
|
||||
cwd:app,stdio:'inherit',windowsHide:true,env:{...process.env,APP_BASE_PATH:app,APP_ENV:'testing',SGF_ISOLATED_REGRESSION_RUN:state.id,STOCK_READ_COMPOSITION_ROOT:root,STOCK_READ_COMPOSITION_OUTPUT:output}});
|
||||
console.log(JSON.stringify({exit_code:r.status,root,native_results:fs.existsSync(output)}));process.exitCode=r.status??1;
|
||||
@@ -0,0 +1,17 @@
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import {spawnSync} from 'node:child_process';
|
||||
const runtime='C:/laragon/data/sgf-tempo/run';
|
||||
const state=JSON.parse(fs.readFileSync(path.join(runtime,'modular-regression-current.json'),'utf8'));
|
||||
if(!/^[a-f0-9]{12}$/.test(state.id)||path.resolve(state.root)!==path.resolve(runtime,'modular-regression-'+state.id)||path.resolve(state.app)!==path.resolve(state.root,'app')||state.port!==55439)throw Error('Unexpected regression host');
|
||||
const output=path.join(state.root,'stock-read-'+Date.now()+'.json');
|
||||
if(process.argv[2]&&!['functional','cross','receipt','return'].includes(process.argv[2]))throw Error('Use no argument, functional, cross <private native proof> or receipt <private root>');
|
||||
const cross=process.argv[2]==='cross';
|
||||
const receipt=process.argv[2]==='receipt';const returns=process.argv[2]==='return';
|
||||
if(cross){const file=path.resolve(process.argv[3]||'');if(!file.startsWith(path.resolve(state.root,'private')+path.sep)||!fs.existsSync(file))throw Error('Unexpected native composition proof');}
|
||||
if(receipt){const root=path.resolve(process.argv[3]||'');if(!root.startsWith(path.resolve(state.root,'private')+path.sep)||!fs.existsSync(path.join(root,'receipt-results.json')))throw Error('Unexpected receipt proof');}
|
||||
if(returns){const root=path.resolve(process.argv[3]||'');if(!root.startsWith(path.resolve(state.root,'private')+path.sep)||!fs.existsSync(path.join(root,'return-results.json')))throw Error('Unexpected native return proof');}
|
||||
const filter=returns?['--filter','test_native_return_book']:process.argv[2]==='functional'?['--filter','test_scoped_exact|test_snapshot']:cross?['--filter','test_purchasing_composition']:receipt?['--filter','test_native_receipt_book']:[];
|
||||
const r=spawnSync('C:/laragon/tools/sgf-tempo/php-8.4.27/php.exe',['vendor/phpunit/phpunit/phpunit','tests/Feature/StockReadTest.php',...filter,'--log-junit',output.replace('.json','.xml')],{
|
||||
cwd:state.app,stdio:'inherit',windowsHide:true,env:{...process.env,SGF_ISOLATED_REGRESSION_RUN:state.id,SGF_STOCK_READ_OUTPUT:output,...(cross?{STOCK_READ_NATIVE_PROOF:path.resolve(process.argv[3])}:{}),...(receipt?{STOCK_RECEIPT_NATIVE_ROOT:path.resolve(process.argv[3])}:{}),...(returns?{STOCK_RETURN_NATIVE_ROOT:path.resolve(process.argv[3])}:{})}});
|
||||
console.log(JSON.stringify({exit_code:r.status,report:output}));process.exitCode=r.status??1;
|
||||
@@ -0,0 +1,21 @@
|
||||
<?php
|
||||
// Postcheck confined to the existing regression host. No client data or credentials printed.
|
||||
putenv('APP_ENV=testing');
|
||||
$runtime='C:/laragon/data/sgf-tempo/run';
|
||||
$state=json_decode(file_get_contents($runtime.'/modular-regression-current.json'),true,512,JSON_THROW_ON_ERROR);
|
||||
if (!preg_match('/^[a-f0-9]{12}$/D',$state['id']) || realpath($state['root'])!==realpath($runtime.'/modular-regression-'.$state['id'])
|
||||
|| realpath($state['app'])!==realpath($state['root'].'/app') || $state['port']!==55439) throw new RuntimeException('Unexpected regression host');
|
||||
require $state['app'].'/vendor/autoload.php';
|
||||
$app=require $state['app'].'/bootstrap/app.php'; $app->make(Illuminate\Contracts\Console\Kernel::class)->bootstrap();
|
||||
App\Shared\Testing\TestingDatabaseGuard::assertSafe();
|
||||
$db=Illuminate\Support\Facades\DB::connection();$db->beginTransaction();
|
||||
try {
|
||||
$db->statement('SET TRANSACTION ISOLATION LEVEL REPEATABLE READ, READ ONLY');
|
||||
$i=$db->selectOne("SELECT current_database() AS database,current_user AS role,inet_server_port() AS port,current_setting('cluster_name') AS cluster");
|
||||
if($i->database!=='sgf_tempo_test'||$i->role!=='sgf_test_runner'||(int)$i->port!==55439||$i->cluster!=='sgf_modular_regression_'.$state['id'])throw new RuntimeException('Unexpected effective identity');
|
||||
$definition=$db->selectOne("SELECT pg_get_constraintdef(oid) AS value FROM pg_constraint WHERE conrelid='permission_grants'::regclass AND conname='permission_grants_scope_check'")->value;
|
||||
$grants=(int)$db->table('permission_grants')->where('permission','stock.view')->count();
|
||||
if(str_contains($definition,'stock.view')||$grants!==0)throw new RuntimeException('QA stock rights were not restored');
|
||||
echo json_encode(['identity'=>$i,'read_only'=>true,'temporary_stock_grants_remaining'=>$grants,'original_constraint_restored_without_stock_right'=>true,
|
||||
'constraint_sha256'=>hash('sha256',$definition),'operational_source_bindings'=>count(config('stock_read.synthetic_sources',[]))],JSON_PRETTY_PRINT|JSON_THROW_ON_ERROR).PHP_EOL;
|
||||
} finally {$db->rollBack();}
|
||||
@@ -0,0 +1,23 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import fs from 'node:fs/promises';
|
||||
import React from 'react';
|
||||
import {renderToStaticMarkup} from 'react-dom/server';
|
||||
import {createServer} from 'vite';
|
||||
const server=await createServer({configFile:false,server:{middlewareMode:true,watch:null},appType:'custom',optimizeDeps:{noDiscovery:true}});
|
||||
try{
|
||||
const {default:Workspace}=await server.ssrLoadModule('/resources/js/StockMenuWorkspace.jsx');
|
||||
const {milliQuantity}=await server.ssrLoadModule('/resources/js/StockReadWorkspace.jsx');
|
||||
const context={token:'qa',company:{id:1,name:'Synthetic company'},unit:{id:7,name:'Synthetic unit'}};
|
||||
const company=p=>p==='company.view',stock=(p,u)=>company(p)||(p==='stock.view'&&u===7);
|
||||
const informational=renderToStaticMarkup(React.createElement(Workspace,{section:'stock_balances',context,can:company,navigate:()=>{},api:()=>{throw Error('SSR must not fetch');}}));
|
||||
assert(informational.includes('Consulta de existências indisponível'));assert(!informational.includes('<form'));
|
||||
const reading=renderToStaticMarkup(React.createElement(Workspace,{section:'stock_balances',context,can:stock,navigate:()=>{},api:()=>{throw Error('SSR must not fetch');}}));
|
||||
for(const text of ['Somente leitura','Existências físicas','Movimentos','Armazéns','Artigos'])assert(reading.includes(text));
|
||||
assert(!reading.includes('Consulta de existências indisponível'));assert(!reading.includes('value="0"'));
|
||||
assert.equal(milliQuantity(null),'Indeterminado');assert.equal(milliQuantity('-2000'),'-2,000');
|
||||
assert.equal(milliQuantity('9007199254740993000'),'9007199254740993,000');
|
||||
const denied=renderToStaticMarkup(React.createElement(Workspace,{section:'stock_balances',context,can:()=>false,navigate:()=>{},api:()=>{}}));
|
||||
assert(!denied.includes('<form'));
|
||||
await fs.writeFile('docs/proofs/stock-read-ui-20261102.json',JSON.stringify({kind:'static_render_and_exact_quantity',company_permission_not_stock_permission:true,read_only_controls:true,unknown_not_zero:true,bigint_exact:true,backend_requests:0,visual_review:false},null,2)+'\n');
|
||||
console.log('Stock read UI: permission separation, read-only controls, unavailable state and exact quantities passed; visual review pending.');
|
||||
}finally{await server.close();}
|
||||
@@ -0,0 +1,48 @@
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import crypto from 'node:crypto';
|
||||
import {spawnSync} from 'node:child_process';
|
||||
const state=JSON.parse(fs.readFileSync('C:/laragon/data/sgf-tempo/run/modular-regression-current.json','utf8'));
|
||||
if(!/^[a-f0-9]{12}$/.test(state.id)||path.resolve(state.root)!==path.resolve('C:/laragon/data/sgf-tempo/run/modular-regression-'+state.id)||state.port!==55439)throw Error('Unexpected QA cluster');
|
||||
const resume=process.argv[2]==='new'?undefined:process.argv[2],step=process.argv[3]||'linked';if(resume&&!/^[a-f0-9]{12}$/.test(resume))throw Error('Bad resume tag');
|
||||
const selectedFilter=process.argv[4];if(selectedFilter&&!/^[a-zA-Z0-9_|]+$/.test(selectedFilter))throw Error('Invalid targeted test filter');
|
||||
if(step==='financial-read-affected'&&resume)throw Error('Financial RefreshDatabase cases require a new owned QA database; retained proof databases are never reset.');
|
||||
if(!['linked','affected','lots','replay','issue-affected','issue-lots','return-native','return-link','return-reversals','return-flow','return-extra','financial-read-affected'].includes(step))throw Error('Unknown isolated receipt/issue step');
|
||||
const tag=resume??crypto.randomBytes(6).toString('hex'),root=path.join(state.root,'private','stock-receipt-'+tag),app=path.join(root,'app');
|
||||
const source=path.resolve('../compras'),database='compras_receipts_test_'+tag,role='stock_receipt_qa_'+tag,reader='stock_receipt_read_'+tag;
|
||||
const php='C:/laragon/tools/sgf-tempo/php-8.4.27/php.exe',psql='C:/Program Files/PostgreSQL/17/bin/psql.exe';
|
||||
const admin=fs.readFileSync(path.join(state.root,'private','admin-password.txt'),'utf8').trim();
|
||||
const pg=(input,roleName='sgf_regression_owner',db='postgres',password=admin)=>{
|
||||
const r=spawnSync(psql,['-h','127.0.0.1','-p','55439','-U',roleName,'-d',db,'-At','-v','ON_ERROR_STOP=1'],{encoding:'utf8',windowsHide:true,env:{...process.env,PGPASSWORD:password},input});
|
||||
if(r.status!==0)throw Error('Isolated PostgreSQL operation failed; inspect private QA state without changing any destination');return r.stdout.trim();
|
||||
};
|
||||
if(pg("SELECT current_setting('cluster_name');")!=='sgf_modular_regression_'+state.id)throw Error('Wrong QA cluster');
|
||||
fs.mkdirSync(app,{recursive:true});
|
||||
if(!resume){
|
||||
const password=crypto.randomBytes(32).toString('hex'),readPassword=crypto.randomBytes(32).toString('hex');
|
||||
pg(`CREATE ROLE ${role} LOGIN PASSWORD '${password}' NOSUPERUSER NOCREATEDB NOCREATEROLE NOREPLICATION NOBYPASSRLS;
|
||||
CREATE DATABASE ${database} OWNER ${role}; REVOKE ALL ON DATABASE ${database} FROM PUBLIC;
|
||||
CREATE ROLE ${reader} LOGIN PASSWORD '${readPassword}' NOSUPERUSER NOCREATEDB NOCREATEROLE NOREPLICATION NOBYPASSRLS;
|
||||
ALTER ROLE ${reader} SET default_transaction_read_only=on; GRANT CONNECT ON DATABASE ${database} TO ${reader};`);
|
||||
fs.writeFileSync(path.join(root,'state.json'),JSON.stringify({tag,root,app,database,role,reader,cluster:'sgf_modular_regression_'+state.id},null,2));
|
||||
fs.writeFileSync(path.join(root,'source-connection.json'),JSON.stringify({driver:'pgsql',host:'127.0.0.1',port:55439,database,username:reader,password:readPassword,charset:'utf8',prefix:'',search_path:'public',sslmode:'prefer'}));
|
||||
fs.writeFileSync(path.join(app,'.env.testing'),`APP_ENV=testing\nAPP_KEY=base64:${crypto.randomBytes(32).toString('base64')}\nAPP_DEBUG=false\nAPP_URL=http://localhost\nDB_CONNECTION=pgsql\nDB_HOST=127.0.0.1\nDB_PORT=55439\nDB_DATABASE=${database}\nDB_USERNAME=${role}\nDB_PASSWORD=${password}\nDB_URL=\nSESSION_DRIVER=array\nCACHE_STORE=array\nQUEUE_CONNECTION=sync\nBCRYPT_ROUNDS=4\nSGF_ISOLATED_REGRESSION_RUN=${state.id}\nCOMMON_PAYABLES_ENABLED=false\nPAYABLE_SOURCE_INSTANCE=\n`);
|
||||
}else{
|
||||
const old=JSON.parse(fs.readFileSync(path.join(root,'state.json'),'utf8'));
|
||||
if(old.database!==database||old.role!==role||path.resolve(old.app)!==path.resolve(app))throw Error('Unexpected resume state');
|
||||
}
|
||||
for(const dir of ['app','bootstrap','config','routes','resources','tests','database'])fs.cpSync(path.join(source,dir),path.join(app,dir),{recursive:true,filter:p=>!p.includes('bootstrap'+path.sep+'cache')});
|
||||
for(const pkg of ['inventory-read','inventory-write','inventory-issue','inventory-return','purchasing','budget-control','payables'])fs.cpSync(path.join(source,'packages',pkg),path.join(app,'packages',pkg),{recursive:true});
|
||||
if(!resume)fs.cpSync(path.join(source,'public/build'),path.join(app,'public/build'),{recursive:true});
|
||||
fs.copyFileSync(path.join(source,'composer.json'),path.join(app,'composer.json'));
|
||||
if(!fs.existsSync(path.join(app,'vendor')))fs.symlinkSync(path.join(source,'vendor'),path.join(app,'vendor'),'junction');
|
||||
for(const dir of ['bootstrap/cache','storage/logs','storage/framework/cache/data','storage/framework/sessions','storage/framework/views'])fs.mkdirSync(path.join(app,dir),{recursive:true});
|
||||
const config=path.join(app,'phpunit-stock.xml');
|
||||
fs.writeFileSync(config,`<phpunit bootstrap="${source.replaceAll('\\','/')}/vendor/autoload.php" colors="true"><php><env name="APP_ENV" value="testing"/><env name="APP_BASE_PATH" value="${app.replaceAll('\\','/')}"/></php></phpunit>`);
|
||||
const firstOutput=path.join(root,step+'-junit.xml');const output=fs.existsSync(firstOutput)?path.join(root,step+'-'+Date.now()+'-junit.xml'):firstOutput;
|
||||
const pest=[path.join(source,'vendor/pestphp/pest/bin/pest'),'--test-directory',path.relative(source,path.join(app,'tests')),'-c',config];
|
||||
const args=step==='return-reversals'?[...pest,path.join(app,'tests/Feature/InventoryReversalsTest.php')]:step==='issue-affected'?[...pest,path.join(app,'tests/Feature/StockOperationsTest.php')]:step==='issue-lots'?[...pest,path.join(app,'tests/Feature/InventoryLotsTest.php'),'--filter','FEFO consumes|expiry and hold']:step==='affected'?[...pest,path.join(app,'tests/Feature/ReceiptStockTest.php')]:step==='lots'?[...pest,path.join(app,'tests/Feature/InventoryLotsTest.php'),path.join(app,'tests/Feature/InventoryLotPolicyTest.php'),'--filter','tracked receipt|preapproved exemption|switching receipt policy']:[path.join(source,'vendor/phpunit/phpunit/phpunit'),'-c',config,path.join(app,'tests/Feature/'+(['return-native','return-link','return-flow','return-extra'].includes(step)?'WarehouseReturnIntegrationTest.php':step==='replay'?'ReceiptAdapterReplayTest.php':'CommonReceiptStockTest.php'))];
|
||||
const selectedArgs=step==='financial-read-affected'?[...pest,path.join(app,'tests/Feature/PayableHistoryImportTest.php'),path.join(app,'tests/Feature/PayablePaymentDecisionTest.php')]:args;
|
||||
const r=spawnSync(php,[...selectedArgs,...(selectedFilter?['--filter',selectedFilter]:step==='return-link'?['--filter','native_receipt_issue|source_options']:step==='return-extra'?['--filter','competing_native|expired_and_maintenance']:step==='return-flow'?['--filter','test_(native_receipt|stale_capacity|decision_failure|undeclared|source_issue|concurrent_native|configured_context|return_signals)']:[]),'--log-junit',output],{cwd:app,stdio:'inherit',windowsHide:true,env:{...process.env,APP_BASE_PATH:app,APP_ENV:'testing',SGF_ISOLATED_REGRESSION_RUN:state.id,STOCK_RECEIPT_ROOT:root,...(step==='financial-read-affected'?{COMMON_PAYABLES_PATH:path.resolve('packages/payables')}:{} )}});
|
||||
if(r.status===0&&['linked','return-link','return-flow'].includes(step))pg(`GRANT USAGE ON SCHEMA public TO ${reader}; GRANT SELECT ON stock_items,warehouses,stock_movements TO ${reader};`, 'sgf_regression_owner',database);
|
||||
console.log(JSON.stringify({exit_code:r.status,tag,step,junit:output,isolated_database:database,root,reader_select_granted:r.status===0&&['linked','return-link','return-flow'].includes(step)}));process.exitCode=r.status??1;
|
||||
@@ -0,0 +1,45 @@
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import crypto from 'node:crypto';
|
||||
|
||||
const sha=data=>crypto.createHash('sha256').update(data).digest('hex');
|
||||
const read=file=>JSON.parse(fs.readFileSync(file,'utf8').replace(/^\uFEFF/,''));
|
||||
const project=process.cwd(),runtime='C:/laragon/data/sgf-tempo/run';
|
||||
const state=read(path.join(runtime,'modular-regression-current.json'));
|
||||
const input=path.resolve(process.argv[2]||'');
|
||||
if(!/^[a-f0-9]{12}$/.test(state.id)||path.resolve(state.root)!==path.resolve(runtime,'modular-regression-'+state.id)
|
||||
||path.dirname(input)!==path.resolve(state.root)||!/^association-concurrency-\d+\.json$/.test(path.basename(input)))throw Error('Unexpected private report');
|
||||
const r=read(input);
|
||||
if(!/^[a-f0-9]{12}$/.test(r.run_tag)||r.identity.database!=='sgf_tempo_test'||r.identity.role!=='sgf_test_runner'||r.identity.port!==55439)throw Error('Unexpected report identity');
|
||||
const manifest=read(path.join(state.root,'private','association-concurrency-'+r.run_tag,'manifest.json'));
|
||||
const source=path.join(project,'app/Modules/Identity/Services/ModuleAssociationReview.php');
|
||||
if(sha(fs.readFileSync(source))!==r.optimized_sha256)throw Error('Measured source changed');
|
||||
const nearest=(values,p)=>values[Math.max(0,Math.ceil(values.length*p)-1)]??null;
|
||||
const rows=[];let complete=0,limited=0;
|
||||
for(const phase of r.phases)for(const workload of ['clients','billing_drafts'])for(const workers of [1,2,4]){
|
||||
const waves=phase.waves.filter(w=>w.workload===workload&&w.configured_workers===workers);
|
||||
const samples=waves.flatMap(w=>w.samples),ok=samples.filter(s=>s.status==='ok');
|
||||
const times=ok.map(s=>s.elapsed_ms).sort((a,b)=>a-b),window=waves.reduce((a,w)=>a+w.measurement_window_ms,0);
|
||||
complete+=ok.length;limited+=samples.length-ok.length;
|
||||
rows.push({index_mode:phase.index_mode,workload,workers,complete_requests:ok.length,limited_requests:samples.length-ok.length,
|
||||
p50_ms:nearest(times,.5),p95_sample_ms:nearest(times,.95),closed_loop_observed_rps:Number((ok.length/(window/1000)).toFixed(2)),
|
||||
observed_max_inflight:waves.map(w=>w.observed_max_inflight),queries:[...new Set(ok.map(s=>s.queries))],
|
||||
max_incremental_php_bytes:Math.max(0,...ok.map(s=>s.incremental_php_bytes)),
|
||||
mean_startup_to_ready_ms:Number((waves.reduce((a,w)=>a+w.startup_to_ready_ms,0)/waves.length).toFixed(3))});
|
||||
}
|
||||
const xmlPath=input.replace(/\.json$/,'.xml'),xml=fs.readFileSync(xmlPath,'utf8');
|
||||
const tag=xml.match(/<testsuite\b([^>]*)>/)?.[1];if(!tag)throw Error('Invalid JUnit');
|
||||
const number=name=>Number(tag.match(new RegExp('\\b'+name+'="(\\d+)"'))?.[1]);
|
||||
const date=r.date.replaceAll('-',''),file='module-association-concurrency-'+date+'.json';
|
||||
const summary={date:r.date,source_sha256:r.optimized_sha256,test:{tests:number('tests'),assertions:number('assertions'),errors:number('errors'),failures:number('failures'),junit_sha256:sha(fs.readFileSync(xmlPath))},
|
||||
method:{independent_php_workers:true,distinct_synthetic_identities:4,requests_per_worker:5,waves_per_case:3,percentiles:'Nearest rank; descriptive samples of 15/30/60 requests only',
|
||||
warm_in_process_http:true,login_network_tls_browser_measured:false,read_only_reader_transactions:true,records_per_area:3000,lines_per_wide_draft:100,description_bytes:1000},
|
||||
results:rows,matrix_complete_requests:complete,matrix_limited_requests:limited,mixed_read_complete_requests:r.mixed_read_wave.complete_requests,mixed_read_max_inflight:r.mixed_read_wave.observed_max_inflight,
|
||||
workload_response_hashes:Object.fromEntries(Object.entries(manifest.workloads).map(([key,value])=>[key,value.response_sha256])),competing_confirmation:r.competing_confirmation,
|
||||
preservation:{previous_qa_rows_and_indexes_unchanged:r.previous_rows_unchanged,new_qa_fixture_retained:true,private_manifest_kept:true,
|
||||
synthetic_confirmation_rolled_back:r.synthetic_confirmation_rolled_back,test_index_removed:r.test_index_removed,protected_six_records_untouched:true,blocked_cache_untouched:true,
|
||||
destination_connected:false,production_rights_changed:false,publication:false,purchasing_activation:false,checkpoint_created:false},
|
||||
artifact:{path:file,sha256:sha(fs.readFileSync(input))},deployment_capacity_validated:false,visual_review_pending:true};
|
||||
fs.copyFileSync(input,path.join(project,'docs/proofs',file));
|
||||
fs.writeFileSync(path.join(project,'docs/proofs','module-association-concurrency-summary-'+date+'.json'),JSON.stringify(summary,null,2)+'\n');
|
||||
console.log(JSON.stringify({test:summary.test,matrix:complete,mixed:summary.mixed_read_complete_requests,limited,preserved:r.previous_rows_unchanged,index_removed:r.test_index_removed},null,2));
|
||||
@@ -0,0 +1,35 @@
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import crypto from 'node:crypto';
|
||||
|
||||
const read=p=>JSON.parse(fs.readFileSync(p,'utf8').replace(/^\uFEFF/,''));
|
||||
const hash=p=>crypto.createHash('sha256').update(fs.readFileSync(p)).digest('hex');
|
||||
const state=read('C:/laragon/data/sgf-tempo/run/modular-regression-current.json');
|
||||
const input=path.resolve(process.argv[2]||'');
|
||||
if(path.dirname(input)!==path.resolve(state.root)||!/^association-http-lock-\d+\.json$/.test(path.basename(input)))throw Error('Unexpected regression proof');
|
||||
const report=read(input);
|
||||
const junit=p=>{
|
||||
const xml=fs.readFileSync(p,'utf8'),suite=xml.match(/<testsuite\b([^>]*)>/)?.[1];
|
||||
const result=Object.fromEntries(['tests','assertions','errors','failures'].map(k=>[k,Number(suite?.match(new RegExp('\\b'+k+'="(\\d+)"'))?.[1])]));
|
||||
if(!result.tests||result.errors!==0||result.failures!==0)throw Error('Incomplete test result');
|
||||
return {...result,sha256:hash(p)};
|
||||
};
|
||||
if(report.identity.database!=='sgf_tempo_test'||report.identity.role!=='sgf_test_runner'||report.identity.port!==55439
|
||||
||report.identity.cluster!=='sgf_modular_regression_'+state.id||!report.completed||!report.previous_qa_rows_and_index_catalog_preserved
|
||||
||report.cases.length!==3||hash('app/Modules/Identity/Http/ModuleRecordsController.php')!==report.controller_sha256
|
||||
||hash('app/Modules/Identity/Services/ModuleAssociationReview.php')!==report.source_sha256)throw Error('Inconsistent proof/source');
|
||||
for(const c of report.cases)if(c.timeout.http_status!==423||c.timeout.code!=='association_busy'||c.duplicate_decision
|
||||
||c.decision_events_after_retry!==1||!c.blocking_parent_verified)throw Error('Unexpected result');
|
||||
const prior=read('docs/proofs/module-association-release-summary-20261102.json');
|
||||
const previous=[prior.proof,...prior.reused_proofs];
|
||||
for(const p of previous)if(hash(p.path)!==p.sha256)throw Error('A valid prior proof changed');
|
||||
const proof='docs/proofs/module-association-http-lock-20261102.json';
|
||||
fs.copyFileSync(input,proof);
|
||||
const summary={date:'2026-11-02',source_sha256:report.source_sha256,controller_sha256:report.controller_sha256,
|
||||
tests:{http_lock:junit(input.replace(/\.json$/,'.xml')),affected_queue:junit(path.join(state.root,'junit-focused.xml'))},cases:report.cases,
|
||||
preservation:{previous_qa_rows_and_index_catalog_preserved:true,prior_proof_hashes_preserved:true,protected_six_records_untouched:true,blocked_cache_untouched:true,
|
||||
destination_connected:false,public_rights_changed:false,indexes_created:false,purchasing_activation:false,publication:false,checkpoint:false,new_qa_fixture_retained:true},
|
||||
proof:{path:proof,sha256:hash(proof)},previous_proofs:previous,method:{http_kernel_in_process:true,independent_lock_contender:true,test_lock_timeout_ms:1000,
|
||||
runtime_timeout_changed:false,automatic_decision_retry_added:false,network_browser_login_tls_tested:false},deployment_capacity_validated:false,visual_review_pending:true};
|
||||
fs.writeFileSync('docs/proofs/module-association-http-lock-summary-20261102.json',JSON.stringify(summary,null,2)+'\n');
|
||||
console.log(JSON.stringify({tests:summary.tests,old_proofs_unchanged:true,cases:report.cases.length},null,2));
|
||||
@@ -0,0 +1,106 @@
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import crypto from 'node:crypto';
|
||||
|
||||
const read=p=>JSON.parse(fs.readFileSync(p,'utf8').replace(/^\uFEFF/,''));
|
||||
const hash=p=>crypto.createHash('sha256').update(fs.readFileSync(p)).digest('hex');
|
||||
const state=read('C:/laragon/data/sgf-tempo/run/modular-regression-current.json');
|
||||
const input=path.resolve(process.argv[2]||'');
|
||||
if(path.dirname(input)!==path.resolve(state.root)||!/^association-release-\d+\.json$/.test(path.basename(input)))throw Error('Unexpected regression report');
|
||||
const r=read(input), xmlPath=input.replace(/\.json$/,'.xml'), xml=fs.readFileSync(xmlPath,'utf8');
|
||||
const suite=xml.match(/<testsuite\b([^>]*)>/)?.[1];
|
||||
const number=k=>Number(suite?.match(new RegExp('\\b'+k+'="(\\d+)"'))?.[1]);
|
||||
const test=Object.fromEntries(['tests','assertions','failures','errors'].map(k=>[k,number(k)]));
|
||||
const projection=read('docs/proofs/module-association-projection-summary-20261102.json');
|
||||
const source='app/Modules/Identity/Services/ModuleAssociationReview.php';
|
||||
if(r.identity.database!=='sgf_tempo_test'||r.identity.role!=='sgf_test_runner'||r.identity.port!==55439
|
||||
||r.identity.cluster!=='sgf_modular_regression_'+state.id||!r.completed||!r.previous_qa_rows_and_index_catalog_preserved
|
||||
||r.cases.length!==4||test.tests!==1||test.failures!==0||test.errors!==0||hash(source)!==r.source_sha256
|
||||
||projection.source.final_sha256!==r.source_sha256)throw Error('Incomplete or inconsistent proof');
|
||||
for(const c of r.cases)if(c.duplicate_decision||!c.wait.actual_wait_observed||!c.wait.blocking_parent_verified)throw Error('Invalid release case');
|
||||
const proof='docs/proofs/module-association-release-20261102.json';
|
||||
fs.copyFileSync(input,proof);
|
||||
const oldProofs=['optimization-comparison','optimization-summary','projection-fanout','projection-summary','concurrency','concurrency-summary'];
|
||||
const summary={date:r.date,source_sha256:r.source_sha256,test:{...test,junit_sha256:hash(xmlPath)},cases:r.cases,
|
||||
preservation:{previous_qa_rows_and_index_catalog_preserved:true,protected_six_records_untouched:true,blocked_cache_untouched:true,
|
||||
destination_connected:false,public_rights_changed:false,indexes_created:false,publication:false,purchasing_activation:false,
|
||||
new_qa_fixture_retained:true,private_manifest_retained:true,functional_code_changed_in_this_followup:false},
|
||||
proof:{path:proof,sha256:hash(proof)},reused_proofs:oldProofs.map(n=>{const p='docs/proofs/module-association-'+n+'-20261102.json';return {path:p,sha256:hash(p)};}),
|
||||
deployment_capacity_validated:false,visual_review_pending:true};
|
||||
fs.writeFileSync('docs/proofs/module-association-release-summary-20261102.json',JSON.stringify(summary,null,2)+'\n');
|
||||
|
||||
const a=read('docs/proofs/module-association-optimization-comparison-20261102.json');
|
||||
const labels={clients:'Clientes',services:'Serviços',service_drafts:'Rascunhos serviço',billing_drafts:'Rascunhos facturação'};
|
||||
const fmt=n=>Number(n).toFixed(2).replace('.',',');
|
||||
const metric=(s,k)=>s.censored?'limite':k==='median_ms'?fmt(s[k]):k==='queries'?String(s[k]):fmt(s[k]/1024);
|
||||
const table=[];
|
||||
for(const d of a.datasets)for(const c of d.cases){
|
||||
const ss=['baseline','batch','indexed_batch'].map(v=>c.summary[v]);
|
||||
table.push(`| ${d.records_per_area} | ${labels[c.area]} / ${c.state} | ${c.page_size} | ${ss.map(s=>metric(s,'median_ms')).join(' / ')} | ${ss.map(s=>metric(s,'queries')).join(' / ')} | ${ss.map(s=>metric(s,'incremental_php_used_bytes')).join(' / ')} |`);
|
||||
}
|
||||
const cases=r.cases.map(c=>`| ${c.case} | ${c.release} | ${c.second_http_status}${c.second_replayed?' (repetição)':''} | ${fmt(c.elapsed_ms)} | ${c.record_version_after} | ${c.decision_events_after_retry} |`).join('\n');
|
||||
const document=`# Fila de associações — consolidação de carga e concorrência
|
||||
|
||||
02/11/2026. **A optimização está implementada localmente.** A listagem usa leitura conjunta da página e das suas dependências em \`describePage/pageStates\`, com lotes limitados, sem materializar o universo. A projecção posterior \`pageColumns\` selecciona metadados e referências em vez dos corpos completos. Prévia e confirmação continuam a obter e revalidar o estado completo actual. Código: [ModuleAssociationReview.php](../${source}). Esta passagem acrescenta provas e documentação; não altera esse código funcional.
|
||||
|
||||
## Espera da confirmação: causa e resultado após libertação
|
||||
|
||||
A prova anterior atingiu \`lock_timeout=1000ms\` (SQLSTATE 55P03, 1 044,222 ms) porque a primeira transacção foi deliberadamente mantida aberta. A nova prova observou a consulta sobre **companies com FOR UPDATE**, o PID da primeira transacção em \`pg_blocking_pids\` e a espera **Lock / transactionid / ShareLock** em \`pg_stat_activity/pg_locks\`. É serialização da confirmação por empresa; o limite não surgiu da leitura conjunta da auditoria. Pode também serializar confirmações de registos diferentes da mesma empresa; esse volume de escritores não foi medido.
|
||||
|
||||
Em quatro cenários novos, a primeira transacção foi libertada depois de observar a espera real e aguardar mais 200 ms. O segundo processo tinha limite de bloqueio de 8 s exclusivamente para esta prova. Os tempos abaixo incluem espera e execução, não representam latência normal nem tempo puro após desbloqueio.
|
||||
|
||||
| Cenário | Libertação da primeira | Segunda confirmação | Tempo total ms | Versão do registo final | Eventos de decisão após repetição |
|
||||
| --- | --- | --- | ---: | ---: | ---: |
|
||||
${cases}
|
||||
|
||||
Após rollback, a segunda decisão gravou exactamente um evento. Após commit de outra decisão, o token preparado antes da espera recebeu **409 por revisão de associações desactualizada**, mesmo mantendo versão documental 1; nova prévia foi possível. O mesmo token da decisão vencedora recebeu sucesso idempotente e o mesmo identificador de evento. Quando a versão documental mudou para 2 durante a espera, o token da versão 1 recebeu **409**, sem decisão; uma prévia da versão 2 foi possível. As repetições não acrescentaram eventos. Os estados de sucesso/409 do segundo processo são resultados do serviço; as repetições e novas prévias foram também verificadas pela API em processo. A apresentação HTTP de timeout de bloqueio continua sem prova própria.
|
||||
|
||||
**Resultado novo: ${test.tests} teste / ${test.assertions} asserções, sem falhas ou erros**, mais quatro trabalhadores concluídos. [Prova](proofs/module-association-release-20261102.json) e [resumo com hashes](proofs/module-association-release-summary-20261102.json). A primeira tentativa desta prova concluiu os quatro cenários mas falhou na comparação final da auditoria por exceder 65 535 parâmetros SQL; corrigiu-se apenas o auxiliar de preservação para IDs inteiros capturados. A execução acima repetiu esta prova afectada e passou; não se repetiram as matrizes ou suites válidas.
|
||||
|
||||
## Comparação A/B/C: leitura conjunta e índices separados
|
||||
|
||||
Medições já válidas, reutilizadas sem nova execução. **A**: checkpoint 340f3bb, consultas por registo; **B**: leitura conjunta, sem índices experimentais, **antes da projecção posterior**; **C**: mesmo B com dois índices experimentais de auditoria. Portanto A→B mede a leitura conjunta e B→C mede separadamente os índices. Não atribuir ao código actual exactamente os tempos de B: a projecção tem outra medição e hash abaixo.
|
||||
|
||||
Mesma fixture imutável em cada volume, 1 000/5 000/10 000 **por cada uma das quatro áreas**, dependências incluídas; três repetições, ordem A/B alternada, C sobre os mesmos dados, kernel e buffers aquecidos. Valores: mediana de tempo; consultas por pedido completo; máximo de memória PHP usada incremental acima do início do pedido, em **KiB**, sem incluir PostgreSQL ou memória total do processo. “limite” significa tentativa interrompida pelo orçamento diagnóstico de 15 s, sem mediana/consultas/memória comparáveis; não é um valor de 15 s nem um ganho calculável. Na prova completa foram 258 leituras medidas concluídas e 13 tentativas limitadas; respostas completas comparáveis idênticas.
|
||||
|
||||
Cada célula contém **A / B / C**. A memória em B pode aumentar face a A, por conservar o conjunto da página; a redução de consultas não implica redução de memória.
|
||||
|
||||
| Registos por área | Área / filtro | Página | Tempo ms A/B/C | Consultas A/B/C | Memória incremental KiB A/B/C |
|
||||
| ---: | --- | ---: | ---: | ---: | ---: |
|
||||
${table.join('\n')}
|
||||
|
||||
Métricas brutas, incluindo picos PHP absolutos, tempos SQL, amostras e hashes: [comparação](proofs/module-association-optimization-comparison-20261102.json). [Método, planos, índices e recusas de acesso](MODULE_ASSOCIATION_OPTIMIZATION_DELIVERY.md). Os dois índices (ligações por módulo e revisão por registo) foram criados apenas no ensaio sintético e desfeitos por rollback/savepoint; nenhum índice no destino.
|
||||
|
||||
## Código actual: projecção e documentos com muitas dependências
|
||||
|
||||
A fase posterior comparou B com a projecção actual, mantendo 10 000 registos por área e 100 linhas por rascunho, páginas 25/100, descrições de 128/1 000 bytes. Foram 132 leituras completas, 44 cenários, respostas equivalentes. A projecção reduziu a memória, mas não acelerou todas as leituras sem índice. Para descrições de 1 000 bytes:
|
||||
|
||||
| Área / página | Tempo ms antes projecção / actual sem índice / actual só índice revisão / actual par | Memória incremental MiB antes / actual |
|
||||
| --- | ---: | ---: |
|
||||
| Serviço / 25 | 668,119 / 728,558 / 97,487 / 225,676 | 8,28 / 4,21 |
|
||||
| Serviço / 100 | 2150,980 / 2861,608 / 341,479 / 408,455 | 32,42 / 16,65 |
|
||||
| Facturação / 25 | 815,219 / 905,073 / 111,725 / 127,118 | 11,55 / 4,85 |
|
||||
| Facturação / 100 | 2720,541 / 2481,159 / 374,043 / 368,403 | 45,49 / 19,23 |
|
||||
|
||||
Consultas: serviço 27/57 e facturação 31/61, páginas 25/100; o crescimento decorre dos lotes das dependências (até 10 000 serviços na página), não do carregamento de todo o universo. Índice de revisão: 13,73 MiB; par: 30,50 MiB. O segundo índice não mostrou benefício consistente; não foi recomendado/aplicado automaticamente. Experiências desfeitas apenas na base sintética. [Relatório e provas desta fase](MODULE_ASSOCIATION_PROJECTION_DELIVERY.md).
|
||||
|
||||
Proveniência SHA-256 do serviço: A **${a.baseline_sha256}**; B/C **${a.optimized_sha256}**; projecção actual e nova prova de libertação **${r.source_sha256}**. A confirmação/prévia não foram alteradas pela optimização.
|
||||
|
||||
## Concorrência de leitura e equivalência preservadas
|
||||
|
||||
Mantêm-se válidas **440 leituras completas**, até quatro identidades/processos com sobreposição efectivamente observada: 420 da matriz e 20 durante confirmação sem commit. Página 25, 3 000 por área, rascunhos com 100 linhas de 1 000 bytes. Todas corresponderam aos hashes autorizados; os leitores viram o estado anterior enquanto a decisão não tinha commit. Não foram repetidas.
|
||||
|
||||
Com quatro processos, clientes: P50/P95 amostral **37,990/60,810 ms sem índice**, **14,790/21,402 ms com revisão**; facturação: **429,457/495,430 ms sem índice**, **102,861/121,342 ms com revisão**. Consultas 11/31, memória incremental máxima aproximada 0,09/4,85 MiB por pedido. Os percentis descrevem amostras pequenas (15/30/60 pedidos); não constituem SLA. O índice desta prova entre processos foi gravado só na regressão para ser visível, depois removido explicitamente, com catálogo anterior igual. [Ondas, tempos e limites](MODULE_ASSOCIATION_CONCURRENCY_REVIEW.md).
|
||||
|
||||
Preservam-se também as provas válidas afectadas: **21 testes / 322 asserções**, equivalência de filtros, ordenação, paginação, isolamento e recusas, versões/dependências, prévia, confirmação, falha e recuperação; ensaios separados de planos e acessos documentados na entrega. Não foram repetidos por não haver alteração funcional nesta continuação.
|
||||
|
||||
## Preservação e limites
|
||||
|
||||
A nova execução verificou fisicamente **sgf_tempo_test / sgf_test_runner / porta 55439 / cluster sgf_modular_regression_${state.id}**. Usou quatro clientes e âmbito novos, exclusivamente de regressão, gravados para visibilidade entre processos e retidos com manifesto privado. A primeira tentativa também deixou a sua pequena fixture sintética nesta base; não se apagou nem reinicializou a base. A execução válida verificou hashes dos registos QA anteriores e igualdade do catálogo de índices. Tokens, contexto e manifestos privados não integram estas provas públicas.
|
||||
|
||||
Os seis registos protegidos, a demonstração 8039, o SGF local, a cache bloqueada, provas válidas e trabalho alheio foram preservados; não se abriu ligação ao destino nesta prova. Sem novos índices, direitos públicos, activação de Compras, publicação ou checkpoint. O auxiliar de preservação foi corrigido, não a aplicação.
|
||||
|
||||
**Capacidade de implantação não validada.** Estes ensaios locais usam kernel HTTP em processo e trabalhadores PHP; não medem navegador, login/MFA, rede/TLS, servidor HTTP implantado, carga sustentada, vários escritores ou recursos completos do servidor. Faltam condições, orçamento de memória, índices/planos e política de espera/repetição próprios do destino. A revisão visual permanece separada e pendente, sem repetir a via de navegador bloqueada.
|
||||
`;
|
||||
fs.writeFileSync('docs/MODULE_ASSOCIATION_LOAD_CONSOLIDATION.md',document.replaceAll('\`','`'));
|
||||
console.log(JSON.stringify({test,source_sha256:r.source_sha256,comparative_rows:table.length,proof},null,2));
|
||||
@@ -0,0 +1,77 @@
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import crypto from 'node:crypto';
|
||||
import {spawnSync} from 'node:child_process';
|
||||
|
||||
const read=p=>JSON.parse(fs.readFileSync(p,'utf8').replace(/^\uFEFF/,''));
|
||||
const hash=p=>crypto.createHash('sha256').update(fs.readFileSync(p)).digest('hex');
|
||||
const state=read('C:/laragon/data/sgf-tempo/run/modular-regression-current.json');
|
||||
if(state.port!==55439||state.id!=='2576427d1939')throw Error('Unexpected isolated cluster');
|
||||
const privateRoot=path.join(state.root,'private/stock-receipt-115c5b17e171');
|
||||
const attrs=t=>Object.fromEntries([...t.matchAll(/(\w+)="([^"]*)"/g)].map(m=>[m[1],m[2]]));
|
||||
const files=['private/stock-receipt-115c5b17e171/return-native-1793639486315-junit.xml',
|
||||
'private/stock-receipt-115c5b17e171/return-native-1793640514403-junit.xml',
|
||||
'private/stock-receipt-cc99dcb17e19/return-reversals-junit.xml','stock-read-1793636847404.xml'];
|
||||
const selected=new Map();
|
||||
const proofs=files.map(name=>{
|
||||
const file=path.join(state.root,name),xml=fs.readFileSync(file,'utf8'),suite=attrs(xml.match(/<testsuite\b[^>]*>/)[0]);
|
||||
if(Number(suite.errors)||Number(suite.failures)||Number(suite.skipped))throw Error('Nonpassing evidence');
|
||||
const cases=[...xml.matchAll(/<testcase\b[^>]*\/>|<testcase\b[^>]*>[\s\S]*?<\/testcase>/g)].map(m=>attrs(m[0].match(/^<testcase\b[^>]*>/)[0]));
|
||||
if(cases.length!==Number(suite.tests))throw Error('Wrong case count');
|
||||
for(const c of cases)selected.set(c.classname+'::'+c.name,{name:c.name,assertions:Number(c.assertions),proof:name});
|
||||
return {file:name,sha256:hash(file),tests:Number(suite.tests),assertions:Number(suite.assertions),errors:0,failures:0};
|
||||
});
|
||||
const artifact='../compras/packages/inventory-return/releases/0.1.0',manifest=read(artifact+'/manifest.json');
|
||||
const manifestHash='2850a4f5cb678acfe3a9dbc7a7d62eb233bc72722ad0de374681c2679c90557c';
|
||||
if(hash(artifact+'/manifest.json')!==manifestHash)throw Error('Artifact changed');
|
||||
for(const [f,sha] of Object.entries(manifest.files))if(hash(artifact+'/'+f)!==sha||hash('packages/inventory-return/'+f)!==sha)throw Error('Writer mismatch');
|
||||
const observerPath=path.join(state.root,'stock-read-1793636847404.json'),observer=read(observerPath);
|
||||
const fixturePath=path.join(privateRoot,'return-results-'+observer.native_fixture_sha256+'.json'),fixture=read(fixturePath);
|
||||
if(hash(fixturePath)!==observer.native_fixture_sha256||fixture.return_movement!==observer.observed_return_movement_id||fixture.physical_milli!==observer.physical_milli)throw Error('Observer provenance mismatch');
|
||||
for(const k of ['tenant','warehouse','item','return_movement'])if(!Number.isSafeInteger(fixture[k])||fixture[k]<1)throw Error('Invalid fixture identity');
|
||||
const connection=read(path.join(privateRoot,'source-connection.json'));
|
||||
if(connection.port!==55439||connection.database!=='compras_receipts_test_115c5b17e171'||connection.username!=='stock_receipt_read_115c5b17e171')throw Error('Wrong reader');
|
||||
const sql=`BEGIN READ ONLY;
|
||||
SELECT json_build_object('cluster',current_setting('cluster_name'),'database',current_database(),'role',current_user,'read_only',current_setting('default_transaction_read_only'),
|
||||
'elevated',(SELECT rolsuper OR rolcreatedb OR rolcreaterole OR rolreplication OR rolbypassrls FROM pg_roles WHERE rolname=current_user),
|
||||
'select_book',has_table_privilege(current_user,'stock_movements','SELECT'),'can_write_book',has_table_privilege(current_user,'stock_movements','INSERT,UPDATE,DELETE'),
|
||||
'select_native_operations',has_table_privilege(current_user,'stock_operations','SELECT'),
|
||||
'movements',(SELECT count(*) FROM stock_movements WHERE tenant_id=${fixture.tenant} AND stock_item_id=${fixture.item} AND warehouse_id=${fixture.warehouse}),
|
||||
'physical_milli',(SELECT sum(quantity_milli)::text FROM stock_movements WHERE tenant_id=${fixture.tenant} AND stock_item_id=${fixture.item} AND warehouse_id=${fixture.warehouse}),
|
||||
'same_return',(SELECT count(*)=1 FROM stock_movements WHERE id=${fixture.return_movement} AND tenant_id=${fixture.tenant} AND movement_type='workshop_return_in' AND quantity_milli=3000)); ROLLBACK;`;
|
||||
const pg=spawnSync('C:/Program Files/PostgreSQL/17/bin/psql.exe',['-h','127.0.0.1','-p','55439','-U',connection.username,'-d',connection.database,'-Atq','-v','ON_ERROR_STOP=1'],{input:sql,encoding:'utf8',windowsHide:true,env:{...process.env,PGPASSWORD:connection.password}});
|
||||
if(pg.status!==0)throw Error('Read-only provenance postcheck failed');
|
||||
const book=JSON.parse(pg.stdout.trim());
|
||||
if(book.cluster!=='sgf_modular_regression_'+state.id||book.read_only!=='on'||book.elevated||book.can_write_book||book.select_native_operations||!book.select_book||book.movements!==3||book.physical_milli!=='8000'||!book.same_return)throw Error('Reader or retained fixture changed');
|
||||
const previous=read('docs/proofs/module-association-http-lock-summary-20261102.json');
|
||||
for(const p of [previous.proof,...previous.previous_proofs])if(hash(p.path)!==p.sha256)throw Error('Prior association evidence changed');
|
||||
const load=read('docs/proofs/stock-read-load-20261102.json');
|
||||
if(hash('packages/inventory-read/src/InventoryReader.php')!==load.source_sha256)throw Error('Load-tested SQL changed');
|
||||
const post=spawnSync('C:/laragon/tools/sgf-tempo/php-8.4.27/php.exe',['scripts/workshop-stock-state.php'],{encoding:'utf8',windowsHide:true});
|
||||
if(post.status!==0)throw Error('SGF temporary rights restoration failed');
|
||||
const uiPath='docs/proofs/native-return-ui-20261102.json',ui=read(uiPath);
|
||||
for(const [f,sha] of Object.entries(ui.inputs_sha256))if(hash('../compras/'+f)!==sha)throw Error('UI evidence stale');
|
||||
const renderPath='docs/proofs/native-return-history-render-20261102.json',render=read(renderPath);
|
||||
if(render.component_sha256!==hash('../compras/resources/js/pages/stock/operations.tsx')||render.page_sha256!==hash(path.join(privateRoot,'native-stock-history-page.json'))||!render.render_passed)throw Error('Render evidence stale');
|
||||
const build=read(path.join(privateRoot,'return-private-build.json'));
|
||||
if(!build.passed||build.manifest_sha256!==hash(path.join(privateRoot,'app/public/build/manifest.json')))throw Error('Private build missing or stale');
|
||||
const analysis=read(path.join(privateRoot,'return-phpstan.json'));if(analysis.result!=='passed'||analysis.errors!==0)throw Error('PHP analysis failed');
|
||||
const sourceFiles=['../compras/app/Services/WarehouseReturns.php','../compras/app/Services/WarehouseReturnMovement.php','../compras/app/Http/Controllers/WarehouseReturnController.php','../compras/app/Services/InventoryReversals.php','../compras/app/Services/SupplyHistory.php','../compras/app/Services/OrganizationAccess.php','../compras/config/stock_returns.php','../compras/routes/web.php','../compras/tests/Feature/WarehouseReturnIntegrationTest.php','../compras/tests/Support/warehouse-return-worker.php','resources/js/StockReadWorkspace.jsx','tests/Feature/StockReadTest.php'];
|
||||
if(!fs.readFileSync('../compras/config/stock_returns.php','utf8').includes("'enabled' => false"))throw Error('Unexpected activation');
|
||||
const report={date:'2026-11-02',increment:'NATIVE-WAREHOUSE-UNUSED-RETURN-SIM-v1',status:'local_synthetic_implementation',proofs,
|
||||
totals:{distinct_tests:selected.size,assertions:[...selected.values()].reduce((n,c)=>n+c.assertions,0)},selected_cases:[...selected.values()],
|
||||
evidence_selection:'18 native cases, one new forecast-boundary case, 16 affected reversal cases, one SGF observer case. Valid overlapping earlier runs are not added. Observer binds an archived retained fixture; subsequent native tests used new isolated tenants.',
|
||||
scenario:{receipt_milli:'10000',acknowledged_delivery_milli:'5000',physical_after_delivery:'5000',declared_unused_return_milli:'3000',physical_after_return:'8000',movements:3,one_shared_writer:true,native_standalone_requires_sgf:false,sgf_read_only_observes_same_movement:true},
|
||||
controls:{individual_unused_declaration:true,independent_physical_receipt:true,original_source_snapshots_preserved:true,append_only_decisions:true,versions_context_permissions_revalidated:true,withdrawal_and_rejection_without_stock_effect:true,source_reversal_after_partial_return_refused:true,transaction_failure_recovery:true,package_failure_503_recovery:true,history_metadata_rendered:true,replenishment_signal_once_inside_transaction:true},
|
||||
concurrency:{real_processes:2,company_row_blocker_observed:true,after_release_same_proposal:'200 replay; one movement',competing_returns:'one confirmed; second 409 after release',withdraw_confirm:'one terminal state in both lock orders',http_lock_timeout:'423 warehouse_return_busy; no effects; explicit retry after release succeeds'},
|
||||
history:read(path.join(privateRoot,'return-history-metrics.json')),forecast_boundary:read(path.join(privateRoot,'return-forecast-boundary.json')),
|
||||
package:{manifest_sha256:manifestHash,canonical_matches_native_artifact:true,writer_unchanged:true},observer:{proof_sha256:hash(observerPath),fixture_archive_sha256:hash(fixturePath),...observer},
|
||||
postchecks:{native_retained_book:book,sgf:JSON.parse(post.stdout)},
|
||||
ui:{proof:uiPath,sha256:hash(uiPath),...ui},history_render:{proof:renderPath,sha256:hash(renderPath),...render},qualified_private_build:build,php_analysis:analysis,
|
||||
source_sha256:Object.fromEntries(sourceFiles.map(f=>[f,hash(f)])),
|
||||
reused_proofs:Object.fromEntries(['docs/proofs/workshop-material-cycle-20261102.json','docs/proofs/stock-read-load-20261102.json','docs/proofs/module-association-http-lock-summary-20261102.json'].map(f=>[f,hash(f)])),
|
||||
preservation:{six_pending_associations_untouched:true,blocked_cache_untouched:true,demos_and_real_data_untouched:true,no_real_rights_granted:true,native_synthetic_profiles_retained_in_separate_qa_database:true,sgf_temporary_rights_restored:true,regression_not_reset:true,existing_proofs_preserved:true,activation:false,publication:false,checkpoint:false},
|
||||
limits:['Native unused quantity is an explicit recipient declaration; no SGF technical-consumption register is consulted by the standalone native flow.','Only internal warehouse deliveries; no lots, equipment/spare parts, reservation-bearing articles, quality or quarantine.','No consumed-material compensation or automatic reversal of confirmed returns.','Historical forecast preserves gross issue convention; net return forecasting is not implemented.','SGF observation is isolated QA evidence, not installation of a full operational composition.','No write-load capacity, visual review, browser interaction or deployment validation.','Qualified private asset build omits optional React compiler and font downloading; default optimized build is not validated.'],
|
||||
visual_review_pending:true,deployment_capacity_validated:false};
|
||||
fs.writeFileSync('docs/proofs/native-warehouse-return-20261102.json',JSON.stringify(report,null,2)+'\n');
|
||||
console.log(JSON.stringify({totals:report.totals,postchecks:report.postchecks,package:report.package},null,2));
|
||||
@@ -0,0 +1,31 @@
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import crypto from 'node:crypto';
|
||||
|
||||
const read=p=>JSON.parse(fs.readFileSync(p,'utf8').replace(/^\uFEFF/,''));
|
||||
const hash=p=>crypto.createHash('sha256').update(fs.readFileSync(p)).digest('hex');
|
||||
const state=read('C:/laragon/data/sgf-tempo/run/modular-regression-current.json'),tag=process.argv[2];
|
||||
if(state.port!==55439||!/^[a-f0-9]{12}$/.test(tag||''))throw Error('Wrong isolated context');
|
||||
const root=path.join(state.root,'private/payable-history-'+tag),regression=read(path.join(root,'regression.json')),http=read(path.join(root,'http-proof.json')),native=read(path.join(root,'native-post.json')),sgf=read(path.join(root,'sgf-post.json'));
|
||||
if(regression.tests!==10||regression.cases.some(c=>!c.passed)||!http.passed||!native.originals_unchanged||native.native_obligations!==0||!sgf.one_obligation||!sgf.one_history_import||!sgf.original_source_unchanged||sgf.default_feature_enabled||sgf.pending_decision_schema_installed||sgf.recognition_events!==1||sgf.history_events!==1)throw Error('Evidence incomplete or inconsistent');
|
||||
const source=read(path.join(root,'source.json'));
|
||||
if(source.native_originals_sha256!==native.originals_sha256||source.capture.confirmed_minor!==3000||source.capture.pending_minor!==1000||source.capture.rejected_minor!==500||source.source.total_minor!==6000||source.source.status!=='validated'||!source.source.validation_audit_id)throw Error('Actual conferred native source differs');
|
||||
const old=read('docs/proofs/module-association-http-lock-summary-20261102.json');
|
||||
for(const p of [old.proof,...old.previous_proofs])if(hash(p.path)!==p.sha256)throw Error('Association evidence changed');
|
||||
const files=['app/Modules/Purchasing/SupplierHistoryReceipts.php','app/Modules/Purchasing/SupplierPayables.php','config/supplier_payable_history.php','app/Modules/Purchasing/SupplierPayablesController.php','routes/web.php','packages/payables/src/Payables.php','packages/payables/src/HistoricalPayments.php','packages/payables/ui/page.php','scripts/payable-history-sgf-run.mjs','scripts/payable-history-sgf-proof.php','scripts/payable-history-sgf-cases.php','scripts/payable-history-sgf-http.mjs','scripts/payable-history-sgf-http-router.php'];
|
||||
if(!fs.readFileSync('config/supplier_payable_history.php','utf8').includes("'synthetic_enabled' => false"))throw Error('Unexpected operational activation');
|
||||
const report={date:'2026-11-02',increment:'SGF-PAYABLE-HISTORY-RECEIPT-SIM-v1',status:'local_synthetic_adapter_implemented',
|
||||
regression,actual_http:http,postchecks:{native,sgf},
|
||||
private_evidence_sha256:Object.fromEntries(['source.json','regression.json','http-proof.json','native-post.json','sgf-post.json'].map(f=>[f,hash(path.join(root,f))])),
|
||||
source_provenance:{actual_native_receipt_and_invoice_workflows:true,actual_original_payment_registration_and_independent_review:true,native_history_capture_by_existing_engine:true,native_common_obligation_recognised:false,source_only_fresh_owned_qa_database:true,bank_details_and_encrypted_references_not_exported:true,receipt_checksum_proves_integrity_not_external_authenticity:true,sgf_receipt_mapping_explicit_in_qa_seed:true},
|
||||
scenario:{currency:'MZN',principal_minor:6000,confirmed_minor:3000,pending_minor:1000,rejected_minor:500,balance_minor:3000,due_on:null,days_overdue:null,no_pending_payment_decision:true,no_new_financial_engine:true},
|
||||
concurrency:{processes:2,company_row_blocker_observed_with_pg_blocking_pids:true,second_waited_then_replayed_original:true,obligations:1,history_imports:1,imported_payments:3},
|
||||
isolation:{dedicated_native_and_sgf_databases:true,port:55439,restricted_qa_roles:true,read_write_permission_and_company_context_revalidated:true,no_real_or_demo_access_grants:true},
|
||||
source_sha256:Object.fromEntries(files.map(f=>[f,hash(f)])),
|
||||
reused_proofs:Object.fromEntries(['docs/proofs/workshop-stock-closure-20261102.json','docs/proofs/native-warehouse-return-20261102.json','docs/proofs/module-association-http-lock-summary-20261102.json'].map(f=>[f,hash(f)])),
|
||||
verification_scope:'Ten directed scenarios (56 assertions) plus 17 actual HTTP checks. Current adapter callbacks, shared controller/UI, receipt validation and concurrency exercised. Native original facts postchecked read-only. Earlier suites not repeated; no JavaScript or Vite build required for this PHP/template change.',
|
||||
preservation:{six_pending_associations_untouched:true,blocked_cache_untouched:true,demos_and_installed_databases_untouched:true,previous_proofs_preserved:true,no_real_migration_or_rights:true,qa_databases_and_evidence_retained:true,purchasing_activation:false,publication:false,checkpoint:false},
|
||||
limits:['Reads explicitly seeded immutable historical receipts, not live native payment history.','No operational receipt publisher/importer, authentication of external provenance or responsibility cutover installed. Hashes alone do not authenticate an external source.','No common financial obligation was recognised in the native source; this is QA adapter evidence, not proof that simultaneously operated native/SGF installations share financial authority.','Pending/rejected original payments retain state; imported pending-payment decisions, new payments and banking are not added in SGF.','Existing financial listing remains unpaginated; no load/capacity claim or general financial consolidation claim.','No accounting postings, operational activation, browser interaction or visual review.'],
|
||||
visual_review_pending:true,deployment_capacity_validated:false};
|
||||
fs.writeFileSync('docs/proofs/sgf-payable-history-receipt-20261102.json',JSON.stringify(report,null,2)+'\n');
|
||||
console.log(JSON.stringify({scenarios:regression.tests,assertions:regression.assertions,http_checks:http.assertions,native_originals_unchanged:native.originals_unchanged,default_feature_enabled:false,operational_cutover_validated:false}));
|
||||
@@ -0,0 +1,49 @@
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import crypto from 'node:crypto';
|
||||
import {spawnSync} from 'node:child_process';
|
||||
const read=p=>JSON.parse(fs.readFileSync(p,'utf8').replace(/^\uFEFF/,''));
|
||||
const hash=p=>crypto.createHash('sha256').update(fs.readFileSync(p)).digest('hex');
|
||||
const state=read('C:/laragon/data/sgf-tempo/run/modular-regression-current.json');
|
||||
const tag=process.argv[2],crossFile=path.resolve(process.argv[3]||''),functionalXml=path.resolve(process.argv[4]||'');
|
||||
if(!/^[a-f0-9]{12}$/.test(tag||''))throw Error('Unexpected composition');
|
||||
for(const p of [crossFile,functionalXml])if(path.dirname(p)!==path.resolve(state.root)||!/^stock-read-\d+\.(json|xml)$/.test(path.basename(p)))throw Error('Unexpected regression proof');
|
||||
const root=path.join(state.root,'private','stock-composition-'+tag),native=read(path.join(root,'native-results.json')),cross=read(crossFile);
|
||||
const nativeState=read(path.join(root,'state.json'));
|
||||
if(nativeState.database!=='stock_read_compras_'+tag||nativeState.role!=='stock_read_qa_'+tag)throw Error('Unexpected native DB');
|
||||
const artifact='../compras/packages/inventory-read/releases/0.1.0',manifest=read(artifact+'/manifest.json');
|
||||
if(hash(artifact+'/manifest.json')!==native.package_manifest_sha256||native.package_manifest_sha256!==cross.native_package_manifest_sha256)throw Error('Artifact mismatch');
|
||||
for(const [file,sha] of Object.entries(manifest.files))if(hash(artifact+'/'+file)!==sha||hash('packages/inventory-read/'+file)!==sha)throw Error('Two implementations differ');
|
||||
if(hash('packages/inventory-read/src/InventoryReader.php')!==cross.reader_sha256||hash('packages/inventory-read/src/InventoryReadSession.php')!==cross.session_sha256)throw Error('Measured common source changed');
|
||||
if(Object.keys(cross.cases).length!==12||!cross.cross_host_token_refused)throw Error('Incomplete comparison');
|
||||
for(const [query,c] of Object.entries(cross.cases))if(!c.equivalent||c.sha256!==native.cases[query]?.sha256)throw Error('Comparison failed');
|
||||
const junit=file=>{
|
||||
const xml=fs.readFileSync(file,'utf8'),suite=xml.match(/<testsuite\b([^>]*)>/)?.[1];
|
||||
const result=Object.fromEntries(['tests','assertions','errors','failures'].map(k=>[k,Number(suite?.match(new RegExp('\\b'+k+'="(\\d+)"'))?.[1])]));
|
||||
if(!result.tests||result.errors!==0||result.failures!==0)throw Error('Invalid JUnit');return {...result,sha256:hash(file)};
|
||||
};
|
||||
const prior=read('docs/proofs/module-association-http-lock-summary-20261102.json');
|
||||
for(const p of [prior.proof,...prior.previous_proofs])if(hash(p.path)!==p.sha256)throw Error('Earlier association proof changed');
|
||||
const oldLoad=read('docs/proofs/stock-read-load-20261102.json');
|
||||
if(oldLoad.source_sha256!==cross.reader_sha256)throw Error('Reader changed since valid load benchmark');
|
||||
const env=fs.readFileSync(path.join(root,'app/.env.testing'),'utf8');
|
||||
const password=env.match(/^DB_PASSWORD=([a-f0-9]+)$/m)?.[1];if(!password)throw Error('Private QA credentials unavailable');
|
||||
const pg=spawnSync('C:/Program Files/PostgreSQL/17/bin/psql.exe',['-h','127.0.0.1','-p','55439','-U',nativeState.role,'-d',nativeState.database,'-At','-v','ON_ERROR_STOP=1'],{
|
||||
encoding:'utf8',windowsHide:true,env:{...process.env,PGPASSWORD:password},input:"BEGIN READ ONLY; SELECT json_build_object('database',current_database(),'role',current_user,'cluster',current_setting('cluster_name'),'superuser',rolsuper,'create_database',rolcreatedb,'create_role',rolcreaterole,'replication',rolreplication,'bypass_rls',rolbypassrls) FROM pg_roles WHERE rolname=current_user; ROLLBACK;"});
|
||||
if(pg.status!==0)throw Error('Native postcheck failed');
|
||||
const nativePost=JSON.parse(pg.stdout.split(/\r?\n/).find(l=>l.startsWith('{')));
|
||||
if(nativePost.database!==nativeState.database||nativePost.cluster!=='sgf_modular_regression_'+state.id||['superuser','create_database','create_role','replication','bypass_rls'].some(k=>nativePost[k]!==false))throw Error('Unexpected native role privileges');
|
||||
const sgfPostRun=spawnSync('C:/laragon/tools/sgf-tempo/php-8.4.27/php.exe',['scripts/stock-read-state.php'],{encoding:'utf8',windowsHide:true});
|
||||
if(sgfPostRun.status!==0)throw Error('SGF temporary rights not restored');
|
||||
const summary={date:'2026-11-02',increment:'COMMON-STOCK-READ-COMPOSITION-SIM-v1',status:'two_application_read_api_compositions_verified',
|
||||
package:{id:manifest.id,version:manifest.version,manifest_sha256:native.package_manifest_sha256,files:manifest.files,identical_verified_artifact:true,customer_dependency_on_sgf_source:false},
|
||||
tests:{compras:junit(path.join(root,'native-junit.xml')),sgf_functional:junit(functionalXml),two_composition_parity:junit(crossFile.replace(/\.json$/,'.xml'))},
|
||||
cases:cross.cases,cross_host_token_refused:true,native_checks:{roles_and_configured_profiles:native.native_roles_and_profiles_revalidated,warehouse_isolation:native.warehouse_isolation,stale_snapshot:native.stale_snapshot_refused,unknown_source:native.unknown_source_refused},
|
||||
source_hashes:{sgf_adapter:hash('app/Modules/Inventory/StockReadController.php'),compras_adapter:hash('../compras/app/Http/Controllers/CommonStockReadController.php')},
|
||||
preservation:{native_read_only_postcheck:nativePost,sgf_read_only_postcheck:JSON.parse(sgfPostRun.stdout),new_native_qa_database_retained:true,existing_databases_migrated:false,
|
||||
prior_association_proofs_unchanged:true,existing_stock_load_reused_sha256:hash('docs/proofs/stock-read-load-20261102.json'),protected_six_records_untouched:true,blocked_cache_untouched:true,
|
||||
public_rights_changed:false,purchasing_candidate_activated:false,demos_updated:false,publication:false,checkpoint:false},
|
||||
limits:['Real application kernels/routes/middleware, preauthenticated synthetic users; no network login/MFA/TLS/browser proof.','Read-only API composition; existing native Stock page/writer unchanged.',
|
||||
'Stock source is QA fixture, not migrated customer data; no receipt/consumption/business command executed.','No new load measurements; reader SQL unchanged and valid historical load retained.'],visual_review_pending:true,deployment_capacity_validated:false};
|
||||
fs.writeFileSync('docs/proofs/stock-read-composition-20261102.json',JSON.stringify(summary,null,2)+'\n');
|
||||
console.log(JSON.stringify({tests:summary.tests,equivalent_cases:12,identical_artifact:true,native_role_restricted:true,sgf_temporary_grants:summary.preservation.sgf_read_only_postcheck.temporary_stock_grants_remaining},null,2));
|
||||
@@ -0,0 +1,38 @@
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import crypto from 'node:crypto';
|
||||
import {spawnSync} from 'node:child_process';
|
||||
const read=p=>JSON.parse(fs.readFileSync(p,'utf8').replace(/^\uFEFF/,''));
|
||||
const hash=p=>crypto.createHash('sha256').update(fs.readFileSync(p)).digest('hex');
|
||||
const state=read('C:/laragon/data/sgf-tempo/run/modular-regression-current.json');
|
||||
const measurement=path.resolve(process.argv[2]||''),functional=path.resolve(process.argv[3]||'');
|
||||
for(const p of [measurement,functional])if(path.dirname(p)!==path.resolve(state.root)||!/^stock-read-\d+\.(json|xml)$/.test(path.basename(p)))throw Error('Unexpected private proof');
|
||||
const metrics=read(measurement);
|
||||
if(hash('packages/inventory-read/src/InventoryReader.php')!==metrics.source_sha256||metrics.profiles.length!==24)throw Error('Source/measurement mismatch');
|
||||
const junit=p=>{
|
||||
const xml=fs.readFileSync(p,'utf8'),suite=xml.match(/<testsuite\b([^>]*)>/)?.[1];
|
||||
const n=k=>Number(suite?.match(new RegExp('\\b'+k+'="(\\d+)"'))?.[1]);
|
||||
if(!n('tests')||n('errors')||n('failures'))throw Error('Invalid JUnit');
|
||||
return {tests:n('tests'),assertions:n('assertions'),errors:n('errors'),failures:n('failures'),sha256:hash(p)};
|
||||
};
|
||||
const previous=read('docs/proofs/module-association-http-lock-summary-20261102.json');
|
||||
for(const p of [previous.proof,...previous.previous_proofs])if(hash(p.path)!==p.sha256)throw Error('Prior evidence changed');
|
||||
for(const p of metrics.profiles)if(p.samples.length!==3||p.samples.some(s=>s.queries>=35))throw Error('Incomplete load profile');
|
||||
const post=spawnSync('C:/laragon/tools/sgf-tempo/php-8.4.27/php.exe',['scripts/stock-read-state.php'],{encoding:'utf8',windowsHide:true});
|
||||
if(post.status!==0)throw Error('QA rights restoration postcheck failed');
|
||||
const preservation=JSON.parse(post.stdout);
|
||||
const files=['packages/inventory-read/src/InventoryReader.php','app/Modules/Inventory/StockReadController.php','config/stock_read.php','resources/js/StockReadWorkspace.jsx','resources/js/StockMenuWorkspace.jsx','routes/web.php','app/Shared/Context/ModuleNavigation.php'];
|
||||
const summary={date:'2026-11-02',increment:'SGF-STOCK-READ-SIM-v1',status:'partial_synthetic_sgf_read_adapter',
|
||||
tests:{initial_stock_suite:junit(measurement.replace(/\.json$/,'.xml')),final_functional:junit(functional),navigation:junit(path.join(state.root,'junit-focused.xml'))},
|
||||
provenance:{reader_sha256:metrics.source_sha256,measured_controller_sha256:metrics.controller_sha256,final_controller_sha256:hash(files[1]),
|
||||
followup:'Error handling for missing/incompatible source and timeout added after load measurement; successful query path/reader unchanged. Final functional cases rerun; load not repeated.'},
|
||||
profiles:metrics.profiles.map(p=>({...p,queries:[...new Set(p.samples.map(s=>s.queries))],max_incremental_php_bytes:Math.max(...p.samples.map(s=>s.incremental_php_bytes))})),
|
||||
measured_requests:72,full_balance_page_traversal:[1000,5000,10000],indexes:'Primary keys only; no experimental or destination indexes',sources:Object.fromEntries(files.map(p=>[p,hash(p)])),
|
||||
preservation:{...preservation,protected_six_records_untouched:true,blocked_cache_untouched:true,previous_proof_hashes_unchanged:true,qa_fixtures_retained:true,real_or_demo_database_connected:false,
|
||||
public_rights_changed:false,purchasing_source_modified:false,purchasing_activated:false,publication:false,checkpoint:false},
|
||||
limits:['Testing-only source bindings, empty by default. No existing demo updated.','Shared read projection uses Purchasing ledger structure; Purchasing HTTP adapter/two-host parity remains pending.',
|
||||
'Physical stock only; reservations, blocked, available and valuation not established.','No stock commands, receipt/consumption linkage or journal engine added.','Static UI/build is not visual review; warm in-process load is not deployment capacity.'],visual_review_pending:true,deployment_capacity_validated:false};
|
||||
fs.copyFileSync(measurement,'docs/proofs/stock-read-load-20261102.json');
|
||||
fs.writeFileSync('docs/proofs/stock-read-summary-20261102.json',JSON.stringify(summary,null,2)+'\n');
|
||||
fs.writeFileSync('docs/proofs/stock-read-postcheck-20261102.json',JSON.stringify(preservation,null,2)+'\n');
|
||||
console.log(JSON.stringify({tests:summary.tests,measured_requests:72,profiles:metrics.profiles.length,temporary_rights_remaining:preservation.temporary_stock_grants_remaining,prior_proofs_unchanged:true,status:summary.status},null,2));
|
||||
@@ -0,0 +1,54 @@
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import crypto from 'node:crypto';
|
||||
import {spawnSync} from 'node:child_process';
|
||||
const read=p=>JSON.parse(fs.readFileSync(p,'utf8').replace(/^\uFEFF/,''));
|
||||
const hash=p=>crypto.createHash('sha256').update(fs.readFileSync(p)).digest('hex');
|
||||
const state=read('C:/laragon/data/sgf-tempo/run/modular-regression-current.json');
|
||||
const [tag,affectedTag,lotsTag]=process.argv.slice(2,5),sgfFile=path.resolve(process.argv[5]||''),functionalXml=path.resolve(process.argv[6]||'');
|
||||
for(const t of [tag,affectedTag,lotsTag])if(!/^[a-f0-9]{12}$/.test(t||''))throw Error('Unexpected QA tag');
|
||||
for(const p of [sgfFile,functionalXml])if(path.dirname(p)!==path.resolve(state.root)||!/^stock-read-\d+\.(json|xml)$/.test(path.basename(p)))throw Error('Unexpected private SGF proof');
|
||||
const root=t=>path.join(state.root,'private','stock-receipt-'+t);
|
||||
const native=read(path.join(root(tag),'receipt-results.json')),sgf=read(sgfFile),source=read(path.join(root(tag),'source-connection.json'));
|
||||
if(!sgf.same_native_movement_observed||!sgf.source_write_refused||!sgf.source_connection_failure_503_and_recovery||!native.fault_atomic_and_recovered||!native.concurrency.one_movement)throw Error('Incomplete link proof');
|
||||
const artifact='../compras/packages/inventory-write/releases/0.1.0',manifest=read(artifact+'/manifest.json');
|
||||
if(hash(artifact+'/manifest.json')!==native.writer_manifest_sha256||native.writer_manifest_sha256!==sgf.writer_manifest_sha256)throw Error('Writer artifact mismatch');
|
||||
for(const [file,sha] of Object.entries(manifest.files))if(hash(artifact+'/'+file)!==sha||hash('packages/inventory-write/'+file)!==sha)throw Error('Writer source differs');
|
||||
const junit=p=>{
|
||||
const xml=fs.readFileSync(p,'utf8'),suite=xml.match(/<testsuite\b([^>]*)>/)?.[1];
|
||||
const result=Object.fromEntries(['tests','assertions','errors','failures'].map(k=>[k,Number(suite?.match(new RegExp('\\b'+k+'="(\\d+)"'))?.[1])]));
|
||||
if(!result.tests||result.errors!==0||result.failures!==0)throw Error('Incomplete JUnit');return {...result,sha256:hash(p)};
|
||||
};
|
||||
const previous=read('docs/proofs/module-association-http-lock-summary-20261102.json');
|
||||
for(const p of [previous.proof,...previous.previous_proofs])if(hash(p.path)!==p.sha256)throw Error('Prior association proof changed');
|
||||
const oldLoad=read('docs/proofs/stock-read-load-20261102.json');
|
||||
if(hash('packages/inventory-read/src/InventoryReader.php')!==oldLoad.source_sha256)throw Error('Reader SQL changed since valid load proof');
|
||||
const pg=(role,password,sql)=>{
|
||||
const r=spawnSync('C:/Program Files/PostgreSQL/17/bin/psql.exe',['-h','127.0.0.1','-p','55439','-U',role,'-d','compras_receipts_test_'+tag,'-At','-v','ON_ERROR_STOP=1'],{encoding:'utf8',windowsHide:true,env:{...process.env,PGPASSWORD:password},input:'BEGIN READ ONLY; '+sql+'; ROLLBACK;'});
|
||||
if(r.status!==0)throw Error('Read-only source postcheck failed');return JSON.parse(r.stdout.split(/\r?\n/).find(l=>l.startsWith('{')));
|
||||
};
|
||||
const roleCheck=pg(source.username,source.password,"SELECT json_build_object('database',current_database(),'role',current_user,'cluster',current_setting('cluster_name'),'default_read_only',current_setting('default_transaction_read_only'),'elevated',rolsuper OR rolcreatedb OR rolcreaterole OR rolreplication OR rolbypassrls,'stock_select',has_table_privilege(current_user,'stock_movements','SELECT'),'stock_write',has_table_privilege(current_user,'stock_movements','INSERT,UPDATE,DELETE'),'receipt_select',has_table_privilege(current_user,'goods_receipts','SELECT')) FROM pg_roles WHERE rolname=current_user");
|
||||
if(roleCheck.database!=='compras_receipts_test_'+tag||roleCheck.role!=='stock_receipt_read_'+tag||roleCheck.cluster!=='sgf_modular_regression_'+state.id||roleCheck.elevated||roleCheck.stock_write||roleCheck.receipt_select||!roleCheck.stock_select||roleCheck.default_read_only!=='on')throw Error('Reader role not restricted');
|
||||
const env=fs.readFileSync(path.join(root(tag),'app/.env.testing'),'utf8'),pw=env.match(/^DB_PASSWORD=([a-f0-9]+)$/m)?.[1];if(!pw)throw Error('Private QA credentials unavailable');
|
||||
const tenant=native.link.tenant;if(!Number.isSafeInteger(tenant)||tenant<1)throw Error('Unexpected QA tenant');
|
||||
const book=pg('stock_receipt_qa_'+tag,pw,`SELECT json_build_object('movements',(SELECT count(*) FROM stock_movements WHERE tenant_id=${tenant}),'physical_milli',(SELECT sum(quantity_milli)::text FROM stock_movements WHERE tenant_id=${tenant}),'receipt_confirmations',(SELECT count(*) FROM audit_logs WHERE tenant_id=${tenant} AND action='receipt.confirmed'),'author_and_date_present',(SELECT bool_and(user_id IS NOT NULL AND created_at IS NOT NULL) FROM audit_logs WHERE tenant_id=${tenant} AND action='receipt.confirmed'),'movement_origin_hash',(SELECT snapshot::jsonb->>'inventory_source_hash' FROM stock_movements WHERE tenant_id=${tenant} LIMIT 1))`);
|
||||
if(book.movements!==1||book.physical_milli!=='4000'||book.receipt_confirmations!==2||!book.author_and_date_present||!/^[a-f0-9]{64}$/.test(book.movement_origin_hash))throw Error('Source evidence changed');
|
||||
const post=spawnSync('C:/laragon/tools/sgf-tempo/php-8.4.27/php.exe',['scripts/stock-read-state.php'],{encoding:'utf8',windowsHide:true});if(post.status!==0)throw Error('Temporary SGF permissions not restored');
|
||||
const paths=['packages/inventory-write/src/ReceiptMovementWriter.php','app/Modules/Inventory/StockReadController.php','../compras/app/Services/ReceiptWorkflow.php','../compras/app/Services/ReceiptStockMovement.php'];
|
||||
const report={date:'2026-11-02',increment:'COMMON-RECEIPT-STOCK-SIM-v1',status:'native_receipt_shared_effect_and_sgf_read_verified',
|
||||
tests:{native_link:junit(path.join(root(tag),'linked-junit.xml')),affected_receipts:junit(path.join(root(affectedTag),'affected-junit.xml')),affected_lot_receipts:junit(path.join(root(lotsTag),'lots-junit.xml')),
|
||||
final_native_adapter_replay:junit(path.join(root(tag),'replay-junit.xml')),sgf_functional:junit(functionalXml),sgf_actual_book_link:junit(sgfFile.replace(/\.json$/,'.xml'))},
|
||||
scenario:{first_received_milli:6000,first_accepted_milli:4000,first_rejected_milli:2000,second_received_milli:2000,second_accepted_milli:0,second_rejected_milli:2000,final_physical_milli:'4000',movement_count:1},
|
||||
source_hash_stale_refused:native.source_version_stale_refused,core_replay_same_movement:native.core_replay_same_movement,fault_atomic_and_recovered:native.fault_atomic_and_recovered,concurrency:native.concurrency,
|
||||
same_native_movement_observed_by_sgf:sgf.same_native_movement_observed,read_does_not_copy_or_mutate_stock:sgf.native_book_unchanged_by_read&&sgf.sgf_book_unchanged_by_read,source_connection_failure_503_and_recovery:true,
|
||||
package:{...manifest,manifest_sha256:native.writer_manifest_sha256,canonical_and_artifact_identical:true},source_hashes:Object.fromEntries(paths.map(p=>[p,hash(p)])),
|
||||
source_postcheck:{reader:roleCheck,book},sgf_postcheck:JSON.parse(post.stdout),
|
||||
preservation:{new_qa_databases_only:true,fixtures_retained:true,real_or_demo_database_connected:false,protected_six_records_untouched:true,blocked_cache_untouched:true,prior_association_proofs_unchanged:true,
|
||||
purchasing_candidates_activated:false,public_rights_changed:false,publication:false,checkpoint:false},
|
||||
reused_proofs:{stock_load_sha256:hash('docs/proofs/stock-read-load-20261102.json'),stock_composition_sha256:hash('docs/proofs/stock-read-composition-20261102.json')},
|
||||
limits:['Existing native receipt validation remains authoritative; no independent SGF receipt preparation/confirmation UI or command delivered.',
|
||||
'Source connection is confined to the local QA book and a SELECT-only role; it is not a required remote connector or production architecture.',
|
||||
'StockWorkflow issues/transfers/corrections and workshop consumption are not extracted by this receipt-only writer.',
|
||||
'PostgreSQL tested; other database drivers not validated in this increment. No new load benchmark or deployment capacity claim.'],visual_review_pending:true,deployment_capacity_validated:false};
|
||||
fs.writeFileSync('docs/proofs/stock-receipt-link-20261102.json',JSON.stringify(report,null,2)+'\n');
|
||||
console.log(JSON.stringify({tests:report.tests,physical_milli:book.physical_milli,movements:book.movements,receipt_audits:book.receipt_confirmations,source_read_only:true,sgf_temporary_stock_grants:report.sgf_postcheck.temporary_stock_grants_remaining},null,2));
|
||||
@@ -0,0 +1,38 @@
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import crypto from 'node:crypto';
|
||||
import {spawnSync} from 'node:child_process';
|
||||
const read=p=>JSON.parse(fs.readFileSync(p,'utf8').replace(/^\uFEFF/,''));
|
||||
const hash=p=>crypto.createHash('sha256').update(fs.readFileSync(p)).digest('hex');
|
||||
const state=read('C:/laragon/data/sgf-tempo/run/modular-regression-current.json');
|
||||
if(state.port!==55439||!/^[a-f0-9]{12}$/.test(state.id))throw Error('Wrong QA host');
|
||||
const attrs=t=>Object.fromEntries([...t.matchAll(/(\w+)="([^"]*)"/g)].map(m=>[m[1],m[2]]));
|
||||
function junit(name,selected=null){
|
||||
const file=path.join(state.root,name),xml=fs.readFileSync(file,'utf8'),suite=attrs(xml.match(/<testsuite\b[^>]*>/)[0]);
|
||||
const cases=[...xml.matchAll(/<testcase\b[^>]*\/>|<testcase\b[^>]*>[\s\S]*?<\/testcase>/g)].map(m=>({...attrs(m[0].match(/^<testcase\b[^>]*>/)[0]),xml:m[0]})).filter(c=>!selected||selected.includes(c.name));
|
||||
if(!cases.length||(selected&&cases.length!==selected.length)||cases.some(c=>/<(failure|error|skipped)\b/.test(c.xml)))throw Error('Selected case not passed');
|
||||
if(!selected&&(Number(suite.errors)!==0||Number(suite.failures)!==0))throw Error('Run not fully passed');
|
||||
return {file:name,sha256:hash(file),tests:cases.length,assertions:cases.reduce((n,c)=>n+Number(c.assertions),0),cases:cases.map(c=>c.name),selected_cases_only:!!selected,parent_run_errors:Number(suite.errors),parent_run_failures:Number(suite.failures)};
|
||||
}
|
||||
const tests={affected_existing:junit('workshop-stock-1793633268880.xml',['test_individual_impact_independence_exact_issue_and_replay','test_client_unverified_fraction_and_unknown_delivery_do_not_debit','test_stale_book_mapping_permission_scope_and_failure_recovery','test_corrected_source_requires_review_and_never_consumes_the_same_origin_twice','test_other_company_cannot_read_or_confirm_origin_and_changed_command_is_refused','test_concurrent_confirmation_has_one_claim_and_movement']),
|
||||
delivery:junit('workshop-stock-1793633372636.xml'),additional_integrity_and_isolation:junit('workshop-stock-1793633481884.xml')};
|
||||
const previousPath='docs/proofs/workshop-stock-20261102.json',previous=read(previousPath);
|
||||
const artifact='../compras/packages/inventory-issue/releases/0.1.0';
|
||||
if(hash(artifact+'/manifest.json')!==previous.package.manifest_sha256)throw Error('Shared writer changed');
|
||||
for(const [file,sha] of Object.entries(previous.package.files))if(hash(artifact+'/'+file)!==sha||hash('packages/inventory-issue/'+file)!==sha)throw Error('Writer no longer matches valid native proofs');
|
||||
for(const file of ['../compras/app/Services/StockIssueMovement.php','../compras/app/Services/StockWorkflow.php'])if(hash(file)!==previous.source_sha256[file])throw Error('Native host changed since native proof');
|
||||
const old=read('docs/proofs/module-association-http-lock-summary-20261102.json');for(const p of [old.proof,...old.previous_proofs])if(hash(p.path)!==p.sha256)throw Error('Previous association evidence changed');
|
||||
const post=spawnSync('C:/laragon/tools/sgf-tempo/php-8.4.27/php.exe',['scripts/workshop-stock-state.php'],{encoding:'utf8',windowsHide:true});if(post.status!==0)throw Error('QA rights/source postcheck failed');
|
||||
const uiPath='docs/proofs/workshop-delivery-ui-20261102.json';
|
||||
const source=['app/Modules/Workshop/WorkshopStock.php','app/Modules/Workshop/Http/WorkshopStockController.php','resources/js/WorkshopStockWorkspace.jsx','routes/web.php','tests/Feature/WorkshopStockTest.php','tests/Support/workshop-stock-worker.php'];
|
||||
const report={date:'2026-11-02',increment:'WORKSHOP-DELIVERY-LINK-SIM-v1',status:'implemented_and_proved_synthetically',tests,totals:{tests:Object.values(tests).reduce((n,t)=>n+t.tests,0),assertions:Object.values(tests).reduce((n,t)=>n+t.assertions,0)},
|
||||
selection_note:'Six affected existing cases passed in a run containing three new fixture-bootstrap errors; those errored cases are excluded. The five delivery cases then passed after bootstrap correction and stricter integrity checks; two additional integrity/isolation cases passed separately. Valid native proofs reused without rerunning unchanged native code.',
|
||||
scenarios_asserted:{initial_milli:'10000',already_issued_milli:'2000',before_link_milli:'8000',after_link_milli:'8000',new_physical_movements:0,total_physical_movements:2,total_operations:1,claims:1,original_movement_unchanged:true,same_book_stock_read:true,preparer_cannot_confirm:true,replay_same_movement:true,competing_proposal_refused:true,quantity_mismatch_refused:true,stale_book_refused:true,eligibility_revocation_refused:true,permission_revocation_refused:true,missing_operation_hash_refused:true,reversal_book_refused:true,other_company_prepare_and_confirm_refused:true,other_company_list_empty:true,failure_rollback_and_retry_without_duplicate:true,fully_depleted_book_can_link_without_debit:true},
|
||||
concurrency_asserted:{processes:2,company_row_lock_observed:true,first_backend_identified_by_pg_blocking_pids:true,after_release:'200/replay/same movement',claims:1,physical_milli:'8000'},
|
||||
source_sha256:Object.fromEntries(source.map(p=>[p,hash(p)])),qa_postcheck:JSON.parse(post.stdout),
|
||||
static_ui:{path:uiPath,sha256:hash(uiPath),...read(uiPath)},build:{passed:true,node:'24.22.1',vite:'8.3.5',manifest_sha256:hash('public/build/manifest.json')},
|
||||
reused_proofs:{workshop_stock:{path:previousPath,sha256:hash(previousPath)},shared_writer_manifest_sha256:previous.package.manifest_sha256,native_issue_tests:previous.tests.native_issues,native_lot_tests:previous.tests.native_lots},
|
||||
preservation:{new_synthetic_fixtures_only:true,regression_not_reset:true,temporary_rights_restored:true,six_protected_records_untouched:true,blocked_cache_untouched:true,demos_untouched:true,prior_association_proofs_unchanged:true,real_data_or_rights_changed:false,purchasing_activation:false,publication:false,checkpoint:false},
|
||||
limits:['Only explicit QA movement-to-order eligibility; no automatic or historical adoption.','Only full equal quantities, simple unreversed common-writer issues, no lots/reservations/spare parts.','Partial allocations, returns, compensation and amendment of confirmed links not implemented.','Operational configuration/installation and one installed procurement-to-consumption scenario remain pending.','No new write-load/deployment capacity proof, no browser interaction or visual review.'],visual_review_pending:true,deployment_capacity_validated:false};
|
||||
fs.writeFileSync('docs/proofs/workshop-delivery-link-20261102.json',JSON.stringify(report,null,2)+'\n');
|
||||
console.log(JSON.stringify({totals:report.totals,qa_postcheck:report.qa_postcheck,physical_before:'8000',physical_after:'8000',new_movements:0},null,2));
|
||||
@@ -0,0 +1,31 @@
|
||||
import fs from 'node:fs';import path from 'node:path';import crypto from 'node:crypto';import {spawnSync} from 'node:child_process';
|
||||
const read=p=>JSON.parse(fs.readFileSync(p,'utf8').replace(/^\uFEFF/,''));const hash=p=>crypto.createHash('sha256').update(fs.readFileSync(p)).digest('hex');
|
||||
const state=read('C:/laragon/data/sgf-tempo/run/modular-regression-current.json');if(state.port!==55439||!/^[a-f0-9]{12}$/.test(state.id))throw Error('Wrong QA host');
|
||||
const attrs=t=>Object.fromEntries([...t.matchAll(/(\w+)="([^"]*)"/g)].map(m=>[m[1],m[2]]));const selected=new Map();
|
||||
const runs=['workshop-stock-1793634406841.xml','workshop-stock-1793634619557.xml','workshop-stock-1793634796037.xml','workshop-stock-1793634950240.xml'];
|
||||
const proofs=runs.map(name=>{const file=path.join(state.root,name),xml=fs.readFileSync(file,'utf8'),suite=attrs(xml.match(/<testsuite\b[^>]*>/)[0]);if(Number(suite.errors)||Number(suite.failures)||Number(suite.skipped))throw Error('Run not wholly passing');
|
||||
const cases=[...xml.matchAll(/<testcase\b[^>]*\/>|<testcase\b[^>]*>[\s\S]*?<\/testcase>/g)].map(m=>attrs(m[0].match(/^<testcase\b[^>]*>/)[0]));if(cases.length!==Number(suite.tests))throw Error('Case count differs');
|
||||
for(const c of cases)selected.set(c.name,{name:c.name,assertions:Number(c.assertions),proof:name});return {file:name,sha256:hash(file),tests:Number(suite.tests),assertions:Number(suite.assertions),errors:0,failures:0};});
|
||||
const artifact='../compras/packages/inventory-return/releases/0.1.0',manifest=read(artifact+'/manifest.json');
|
||||
if(hash(artifact+'/manifest.json')!=='2850a4f5cb678acfe3a9dbc7a7d62eb233bc72722ad0de374681c2679c90557c')throw Error('Return artifact changed');
|
||||
for(const [f,sha] of Object.entries(manifest.files))if(hash(artifact+'/'+f)!==sha||hash('packages/inventory-return/'+f)!==sha)throw Error('Canonical return writer/artifact mismatch');
|
||||
const native=read('docs/proofs/workshop-stock-20261102.json');for(const f of ['../compras/app/Services/StockIssueMovement.php','../compras/app/Services/StockWorkflow.php'])if(hash(f)!==native.source_sha256[f])throw Error('Native issue host changed since valid proof');
|
||||
for(const [f,sha] of Object.entries(native.package.files))if(hash('packages/inventory-issue/'+f)!==sha)throw Error('Issue primitive changed');
|
||||
const previous=read('docs/proofs/module-association-http-lock-summary-20261102.json');for(const p of [previous.proof,...previous.previous_proofs])if(hash(p.path)!==p.sha256)throw Error('Association proof changed');
|
||||
const load=read('docs/proofs/stock-read-load-20261102.json');if(hash('packages/inventory-read/src/InventoryReader.php')!==load.source_sha256)throw Error('Reader changed since load proof');
|
||||
const post=spawnSync('C:/laragon/tools/sgf-tempo/php-8.4.27/php.exe',['scripts/workshop-stock-state.php'],{encoding:'utf8',windowsHide:true});if(post.status!==0)throw Error('Rights/source restoration postcheck failed');
|
||||
const files=['app/Modules/Workshop/WorkshopStock.php','app/Modules/Workshop/Http/WorkshopStockController.php','resources/js/WorkshopStockWorkspace.jsx','tests/Feature/WorkshopStockTest.php','tests/Support/workshop-stock-worker.php','packages/inventory-return/src/DeliveryReturnWriter.php','routes/web.php'];
|
||||
const ui='docs/proofs/workshop-material-cycle-ui-20261102.json';const report={date:'2026-11-02',increment:'WORKSHOP-MATERIAL-CYCLE-SIM-v1',status:'local_synthetic_implementation',proofs,
|
||||
selection:'Latest passing evidence selected per test name; repeated affected cases are not counted twice. Earlier intermediate failure is excluded. Later warehouse receipt declaration, history controls and ordered opening-stock timestamp were verified only in affected cases.',
|
||||
totals:{distinct_tests:selected.size,assertions:[...selected.values()].reduce((n,c)=>n+c.assertions,0)},selected_cases:[...selected.values()],
|
||||
scenario_asserted:{opening_milli:'10000',delivered_milli:'5000',physical_after_issue:'5000',consumed_allocation_milli:'2000',physical_after_allocation:'5000',returned_milli:'3000',physical_after_return:'8000',unallocated_after_return:'0',total_movements:3,original_issue_unchanged:true,opening_entry_precedes_issue:true,stock_read_sees_same_book:true,explicit_warehouse_receipt_required:true,service_and_writer_replay_original_return:true},
|
||||
partial_allocations_asserted:{multiple_origins_one_delivery:true,quantity_bound_by_delivery:true,physical_effect_zero:true,stale_remaining_refused:true,already_allocated_origin_not_consumed_again:true,disabling_capability_does_not_erase_history_protection:true,failure_atomic_and_retry_once:true},
|
||||
return_asserted:{consumed_client_unverified_unrepresentable_and_excess_refused:true,stale_capacity_refused:true,independent_confirmation_required:true,missing_receiving_declaration_422:true,right_revoked_403:true,stale_context_409:true,other_company_403_and_empty_history:true,decision_failure_rolls_back_movement_and_operation:true},
|
||||
concurrency_asserted:{processes:2,company_row_blocker_observed_with_pg_blocking_pids:true,competing_allocations_3_plus_3_against_5:'first associates 3; second after release refuses 409',competing_returns_3_plus_3_against_5:'one positive movement of 3; second after release refuses 409'},
|
||||
history_asserted:{server_page_size:25,proposal_fixture_count:51,descending_id_order:true,pages_disjoint:true,status_filters:['all','pending','confirmed'],confirmed_includes_allocations_and_returns:true,unknown_parameters_refused:true,tenant_company_unit_isolation:true},
|
||||
package:{...manifest,manifest_sha256:hash(artifact+'/manifest.json'),canonical_identical:true,native_return_command_integrated:false},source_sha256:Object.fromEntries(files.map(f=>[f,hash(f)])),qa_postcheck:JSON.parse(post.stdout),
|
||||
ui:{path:ui,sha256:hash(ui),...read(ui)},build:{passed:true,node:'24.22.1',vite:'8.3.5',manifest_sha256:hash('public/build/manifest.json')},
|
||||
reused_proofs:Object.fromEntries(['docs/proofs/workshop-delivery-link-20261102.json','docs/proofs/workshop-stock-20261102.json','docs/proofs/stock-read-load-20261102.json','docs/proofs/stock-receipt-link-20261102.json'].map(p=>[p,hash(p)])),
|
||||
preservation:{new_qa_schemas_and_actors_only:true,regression_not_reset:true,six_protected_records_untouched:true,blocked_cache_untouched:true,demos_untouched:true,previous_association_proofs_unchanged:true,real_rights_or_data_changed:false,indices_in_qa_fixture_only:true,purchasing_activation:false,publication:false,checkpoint:false},
|
||||
limits:['One origin may not span multiple deliveries.','Consumed-material compensation and amendment/release of confirmed allocations are not implemented.','Returns accept quantity only: no lots, reservations, quality/quarantine or spare parts.','Native Purchasing return command not integrated; local artifact alone is not runtime integration.','Operational installer and one installed procurement-to-workshop composition pending.','No write-load benchmark or deployment-capacity evidence; no browser interaction or visual review.'],visual_review_pending:true,deployment_capacity_validated:false};
|
||||
fs.writeFileSync('docs/proofs/workshop-material-cycle-20261102.json',JSON.stringify(report,null,2)+'\n');console.log(JSON.stringify({totals:report.totals,postcheck:report.qa_postcheck,return_manifest:report.package.manifest_sha256},null,2));
|
||||
@@ -0,0 +1,54 @@
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import crypto from 'node:crypto';
|
||||
import {spawnSync} from 'node:child_process';
|
||||
|
||||
const read=p=>JSON.parse(fs.readFileSync(p,'utf8').replace(/^\uFEFF/,''));
|
||||
const hash=p=>crypto.createHash('sha256').update(fs.readFileSync(p)).digest('hex');
|
||||
const state=read('C:/laragon/data/sgf-tempo/run/modular-regression-current.json');
|
||||
if(state.port!==55439||!/^[a-f0-9]{12}$/.test(state.id))throw Error('Wrong QA host');
|
||||
const names=process.argv.slice(2);if(!names.length||names.some(n=>!/^workshop-stock-\d+\.xml$/.test(n)))throw Error('Supply passing isolated JUnit file names');
|
||||
const attrs=t=>Object.fromEntries([...t.matchAll(/(\w+)="([^"]*)"/g)].map(m=>[m[1],m[2]]));
|
||||
const selected=new Map();
|
||||
const proofs=names.map(name=>{
|
||||
const file=path.join(state.root,name),xml=fs.readFileSync(file,'utf8'),suite=attrs(xml.match(/<testsuite\b[^>]*>/)[0]);
|
||||
const rawCases=[...xml.matchAll(/<testcase\b[^>]*\/>|<testcase\b[^>]*>[\s\S]*?<\/testcase>/g)].map(m=>m[0]);
|
||||
const cases=rawCases.map(c=>({...attrs(c.match(/^<testcase\b[^>]*>/)[0]),passed:!/<(?:failure|error|skipped)\b/.test(c)}));
|
||||
if(cases.length!==Number(suite.tests))throw Error('Unexpected case count');
|
||||
for(const c of cases)if(c.passed)selected.set(c.name,{name:c.name,assertions:Number(c.assertions),proof:name});
|
||||
return {file:name,sha256:hash(file),tests:Number(suite.tests),assertions:Number(suite.assertions),failures:Number(suite.failures),errors:Number(suite.errors),selected_passing_cases:cases.filter(c=>c.passed).length};
|
||||
});
|
||||
const source=fs.readFileSync('tests/Feature/WorkshopStockTest.php','utf8');
|
||||
const methods=[...source.matchAll(/public function (test_\w+)\(/g)].map(m=>m[1]);
|
||||
if(methods.some(m=>!selected.has(m))||selected.size!==methods.length)throw Error('Current affected suite not covered by selected passing evidence');
|
||||
const uiPath='docs/proofs/workshop-stock-closure-ui-20261102.json',ui=read(uiPath);
|
||||
if(ui.source_sha256!==hash('resources/js/WorkshopStockWorkspace.jsx'))throw Error('UI evidence stale');
|
||||
const old=read('docs/proofs/module-association-http-lock-summary-20261102.json');
|
||||
for(const p of [old.proof,...old.previous_proofs])if(hash(p.path)!==p.sha256)throw Error('Accepted association evidence changed');
|
||||
const reader=read('docs/proofs/stock-read-load-20261102.json');
|
||||
if(reader.source_sha256!==hash('packages/inventory-read/src/InventoryReader.php'))throw Error('Reader SQL changed');
|
||||
const native=read('docs/proofs/native-warehouse-return-20261102.json');
|
||||
if(native.package.manifest_sha256!==hash('../compras/packages/inventory-return/releases/0.1.0/manifest.json'))throw Error('Return artifact changed');
|
||||
const post=spawnSync('C:/laragon/tools/sgf-tempo/php-8.4.27/php.exe',['scripts/workshop-stock-state.php'],{encoding:'utf8',windowsHide:true});
|
||||
if(post.status!==0)throw Error('QA restoration postcheck failed');
|
||||
const files=['app/Modules/Workshop/WorkshopStock.php','app/Modules/Workshop/Http/WorkshopStockController.php','resources/js/WorkshopStockWorkspace.jsx','routes/web.php','tests/Feature/WorkshopStockTest.php','tests/Support/workshop-stock-worker.php','config/workshop_stock.php'];
|
||||
const manifest=read('public/build/manifest.json');const asset='public/build/'+manifest['resources/js/app.jsx'].file;
|
||||
const bundled=fs.readFileSync(asset,'utf8');
|
||||
if(!bundled.includes('Confirmar retirada sem movimento')||!bundled.includes('state_version')||!bundled.includes('source_hash'))throw Error('Current closure UI not in built application');
|
||||
const closure=[...selected.values()].filter(c=>c.name.startsWith('test_closure_'));
|
||||
const report={date:'2026-11-02',increment:'WORKSHOP-STOCK-PROPOSAL-CLOSURE-SIM-v1',status:'local_synthetic_implementation',proofs,
|
||||
selection:'All current WorkshopStockTest cases covered exactly once by name, selecting passing cases only. Initial history assertion omitted the schema-discovery SELECT: its failure is retained, excluded and replaced by the targeted passing rerun distinguishing one catalogue lookup and one batch data lookup. Other passing cases were reused. No native, association or unrelated suites repeated.',
|
||||
totals:{distinct_tests:selected.size,assertions:[...selected.values()].reduce((n,c)=>n+c.assertions,0)},
|
||||
closure_cases:{tests:closure.length,assertions:closure.reduce((n,c)=>n+c.assertions,0)},selected_cases:[...selected.values()],
|
||||
workflow:{withdraw_requires_original_preparer_and_current_prepare_right:true,reject_requires_independent_current_confirmer:true,explicit_reason:true,presented_state_version_and_source_hash_required:true,stale_physical_preview_can_be_closed_without_execution:true,confirmed_effect_cannot_be_closed_or_reversed_by_this_command:true,replay_same_body_actor_and_state_only:true,new_review_requires_new_proposal:true,zero_stock_effect:true,original_immutable:true,append_only_decision_with_before_after_actor_time_reason:true,audit_in_same_transaction:true},
|
||||
concurrency:{separate_processes:2,company_lock_observed_with_pg_blocking_pids:true,withdraw_then_confirm:'second refuses 409 after release; no physical movement',confirm_then_withdraw:'second refuses 409 after release; original movement retained',identical_withdrawals:'second replays original; one decision'},
|
||||
history:{...read(path.join(state.root,'private/workshop-closure-history.json')),filters:['all','pending','confirmed','withdrawn','rejected'],repeatable_read_read_only:true,no_universe_loaded:true},
|
||||
recovery:{audit_failure_rolls_back_decision:true,retry_creates_one_decision:true,database_update_of_closure_refused:true},
|
||||
source_sha256:Object.fromEntries(files.map(f=>[f,hash(f)])),ui:{proof:uiPath,sha256:hash(uiPath),...ui},
|
||||
build:{passed:true,manifest_sha256:hash('public/build/manifest.json'),application_asset:asset,application_asset_sha256:hash(asset),closure_controls_in_built_application:true,modules_transformed:46,navigation_source_changed:false,extra_navigation_checker:'Existing home checker did not complete; only its verified own processes stopped. Direct Vite build and targeted closure UI proof passed. No new navigation acceptance claimed.',browser_interactions_tested:false,visual_review:false},
|
||||
postcheck:JSON.parse(post.stdout),
|
||||
reused_proofs:Object.fromEntries(['docs/proofs/workshop-material-cycle-20261102.json','docs/proofs/native-warehouse-return-20261102.json','docs/proofs/stock-read-load-20261102.json'].map(f=>[f,hash(f)])),
|
||||
preservation:{new_closure_table_and_index_only_in_new_synthetic_fixture_schemas:true,no_installed_migration:true,no_new_rights:true,temporary_qa_rights_restored:true,six_pending_associations_untouched:true,blocked_cache_untouched:true,demos_and_real_data_untouched:true,regression_not_reset:true,previous_proofs_preserved:true,publication:false,activation:false,checkpoint:false},
|
||||
limits:['Closing a proposal does not correct a confirmed physical effect.','Legacy synthetic sources without ws_closures retain read/confirm capability; closure command returns 503 and UI does not offer closure. No operational installer created.','No change to native forecasting convention, lots, reservations or financial/accounting effects.','No browser interaction, visual acceptance or deployment-capacity validation.'],visual_review_pending:true,deployment_capacity_validated:false};
|
||||
fs.writeFileSync('docs/proofs/workshop-stock-closure-20261102.json',JSON.stringify(report,null,2)+'\n');
|
||||
console.log(JSON.stringify({totals:report.totals,closure_cases:report.closure_cases,postcheck:report.postcheck},null,2));
|
||||
@@ -0,0 +1,55 @@
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import crypto from 'node:crypto';
|
||||
import {spawnSync} from 'node:child_process';
|
||||
const read=p=>JSON.parse(fs.readFileSync(p,'utf8').replace(/^\uFEFF/,''));
|
||||
const hash=p=>crypto.createHash('sha256').update(fs.readFileSync(p)).digest('hex');
|
||||
const s=read('C:/laragon/data/sgf-tempo/run/modular-regression-current.json');
|
||||
if(s.port!==55439||!/^[a-f0-9]{12}$/.test(s.id))throw Error('Wrong QA host');
|
||||
const attrs=text=>Object.fromEntries([...text.matchAll(/([\w]+)="([^"]*)"/g)].map(m=>[m[1],m[2]]));
|
||||
function junit(file,selected=null){
|
||||
const xml=fs.readFileSync(file,'utf8'),a=attrs(xml.match(/<testsuite\b[^>]*>/)?.[0]??'');
|
||||
const cases=[...xml.matchAll(/<testcase\b[^>]*\/>|<testcase\b[^>]*>[\s\S]*?<\/testcase>/g)].map(m=>({text:m[0],...attrs(m[0].match(/^<testcase\b[^>]*>/)[0])}));
|
||||
const picked=cases.filter(c=>!selected||selected.includes(c.name));
|
||||
if(!picked.length||(selected&&picked.length!==selected.length)||picked.some(c=>/<(failure|error|skipped)\b/.test(c.text)))throw Error('Selected proof not passing: '+file);
|
||||
if(!selected&&(Number(a.errors)!==0||Number(a.failures)!==0))throw Error('Suite not passing');
|
||||
return {file:path.relative(s.root,file).replaceAll('\\','/'),sha256:hash(file),tests:picked.length,assertions:picked.reduce((n,c)=>n+Number(c.assertions),0),cases:picked.map(c=>c.name),selection:!!selected,whole_run_tests:Number(a.tests),whole_run_failures:Number(a.failures),whole_run_errors:Number(a.errors)};
|
||||
}
|
||||
const base=s.root;
|
||||
const reused=junit(path.join(base,'workshop-stock-1793632450233.xml'),['test_individual_impact_independence_exact_issue_and_replay','test_client_unverified_fraction_and_unknown_delivery_do_not_debit','test_stale_book_mapping_permission_scope_and_failure_recovery','test_concurrent_confirmation_has_one_claim_and_movement']);
|
||||
const corrected=junit(path.join(base,'workshop-stock-1793632791460.xml'));
|
||||
const isolated=junit(path.join(base,'workshop-stock-1793633025634.xml'));
|
||||
const native=junit(path.join(base,'private/stock-receipt-54f39ed20d16/issue-affected-junit.xml'));
|
||||
const lots=junit(path.join(base,'private/stock-receipt-0119bb873c56/issue-lots-junit.xml'));
|
||||
const navigation=junit(path.join(base,'junit-focused.xml'));
|
||||
if(navigation.tests!==8||navigation.assertions!==90)throw Error('Unexpected navigation proof');
|
||||
const artifact='../compras/packages/inventory-issue/releases/0.1.0',manifest=read(artifact+'/manifest.json');
|
||||
if(hash(artifact+'/manifest.json')!=='3c24d9e9fb99bfbb274077495c3a42ddb7eccab06b5c6a956cd739b2bc9b56f0')throw Error('Artifact changed');
|
||||
for(const [f,sha] of Object.entries(manifest.files))if(hash(artifact+'/'+f)!==sha||hash('packages/inventory-issue/'+f)!==sha)throw Error('Artifact/source mismatch');
|
||||
for(const tag of ['54f39ed20d16','0119bb873c56']){
|
||||
const tested=path.join(base,'private/stock-receipt-'+tag+'/app/packages/inventory-issue/releases/0.1.0');
|
||||
if(hash(tested+'/manifest.json')!==hash(artifact+'/manifest.json'))throw Error('Tested artifact differs');
|
||||
for(const [f,sha] of Object.entries(manifest.files))if(hash(tested+'/'+f)!==sha)throw Error('Tested writer differs');
|
||||
if(hash(path.join(base,'private/stock-receipt-'+tag+'/app/app/Services/StockIssueMovement.php'))!==hash('../compras/app/Services/StockIssueMovement.php'))throw Error('Tested native adapter differs');
|
||||
}
|
||||
const prior=read('docs/proofs/module-association-http-lock-summary-20261102.json');
|
||||
for(const p of [prior.proof,...prior.previous_proofs])if(hash(p.path)!==p.sha256)throw Error('Previous association proof changed');
|
||||
const load=read('docs/proofs/stock-read-load-20261102.json');
|
||||
if(hash('packages/inventory-read/src/InventoryReader.php')!==load.source_sha256)throw Error('Reader changed since load test');
|
||||
const post=spawnSync('C:/laragon/tools/sgf-tempo/php-8.4.27/php.exe',['scripts/workshop-stock-state.php'],{encoding:'utf8',windowsHide:true});
|
||||
if(post.status!==0)throw Error('QA postcheck failed: '+post.stderr);
|
||||
const source=['app/Modules/Workshop/WorkshopStock.php','app/Modules/Workshop/Http/WorkshopStockController.php','resources/js/WorkshopStockWorkspace.jsx','resources/js/WorkshopMenuWorkspace.jsx','config/workshop_stock.php','tests/Feature/WorkshopStockTest.php','tests/Support/workshop-stock-worker.php','../compras/app/Services/StockIssueMovement.php','../compras/app/Services/StockWorkflow.php'];
|
||||
const tests={reused_passing_cases:reused,corrected_source:corrected,scope_and_command:isolated,native_issues:native,native_lots:lots,navigation};
|
||||
const report={date:'2026-11-02',increment:'WORKSHOP-STOCK-ISSUE-SIM-v1',status:'local_synthetic_implementation',tests,totals:{tests:Object.values(tests).reduce((n,t)=>n+t.tests,0),assertions:Object.values(tests).reduce((n,t)=>n+t.assertions,0)},
|
||||
proof_selection:'Four passing cases reused from an intermediate five-case run with one fixture-date failure. Only the corrected case and the additional scope case were rerun; the failed case is excluded from reused results.',
|
||||
scenario_asserted:{initial_physical_milli:'10000',consumption_milli:'2000',final_physical_milli:'8000',movements_added:1,claims_added:1,same_book_observed_by_stock:true,preparer_cannot_confirm:true,replay_original_movement:true,stale_book_map_rights_context_refused:true,unknown_previous_issue_refused:true,client_delivered_unverified_fraction_insufficient_refused:true,failure_after_claim_insert_atomic_and_recovered:true,other_company_sources_and_proposals_not_visible:true,other_company_prepare_and_confirm_refused:true,wrong_module_context_refused:true,changed_idempotency_body_refused:true},
|
||||
concurrency_asserted:{processes:2,lock:'company row held by first transaction',pg_blocking_pids_observed:true,second_after_release:'200 replay same movement',one_movement_and_claim:true},
|
||||
correction_asserted:{old_proposal_after_source_correction:'409',verified_v2_after_new_review_physical_milli:'7000',verified_v3_after_consumption:'409 without second effect'},
|
||||
package:{...manifest,manifest_sha256:hash(artifact+'/manifest.json'),source_and_artifact_equal:true},source_sha256:Object.fromEntries(source.map(p=>[p,hash(p)])),
|
||||
qa_postcheck:JSON.parse(post.stdout),static_ui:{proof:'docs/proofs/workshop-stock-ui-20261102.json',sha256:hash('docs/proofs/workshop-stock-ui-20261102.json'),...read('docs/proofs/workshop-stock-ui-20261102.json')},
|
||||
build:{tool:'pinned Node 24.22.1 / Vite 8.3.5',passed:true,manifest_sha256:hash('public/build/manifest.json'),visual_review:false},
|
||||
reused_evidence_sha256:Object.fromEntries(['docs/proofs/stock-receipt-link-20261102.json','docs/proofs/stock-read-load-20261102.json','docs/proofs/stock-read-composition-20261102.json'].map(p=>[p,hash(p)])),
|
||||
preservation:{writes_confined_to_new_synthetic_fixtures:true,regression_not_reset:true,six_protected_records_untouched:true,blocked_cache_untouched:true,demos_untouched:true,previous_association_proofs_unchanged:true,real_grants_or_data_changed:false,purchasing_candidate_activation:false,publication:false,checkpoint:false},
|
||||
limits:['Quantity-only new eligible workshop origins; lots/reservations/spare parts refused.','Reconciliation of existing deliveries, partial allocation, returns and compensation not implemented.','Initial stock in workshop fixture is synthetic; previous native receipt proof is separate, not an installed single end-to-end ERP scenario.','Conference tables are QA fixture only; operational installer and real rights not applied.','Source list paginated 25, latest 25 proposals only.','PostgreSQL tested; new write-load capacity and other drivers unvalidated.'],visual_review_pending:true,deployment_capacity_validated:false};
|
||||
fs.writeFileSync('docs/proofs/workshop-stock-20261102.json',JSON.stringify(report,null,2)+'\n');
|
||||
console.log(JSON.stringify({totals:report.totals,postcheck:report.qa_postcheck,artifact_sha256:report.package.manifest_sha256,visual_review_pending:true,deployment_capacity_validated:false},null,2));
|
||||
@@ -0,0 +1,16 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import fs from 'node:fs/promises';
|
||||
import crypto from 'node:crypto';
|
||||
import React from 'react';
|
||||
import {renderToStaticMarkup} from 'react-dom/server';
|
||||
import {createServer} from 'vite';
|
||||
const server=await createServer({configFile:false,server:{middlewareMode:true,watch:null},appType:'custom',optimizeDeps:{noDiscovery:true}});
|
||||
try {
|
||||
const {WorkshopStockImpact}=await server.ssrLoadModule('/resources/js/WorkshopStockWorkspace.jsx');
|
||||
const html=renderToStaticMarkup(React.createElement(WorkshopStockImpact,{impact:{mode:'link_existing_delivery',physical_before:'8000',physical_after:'8000',quantity_milli:'0',linked_quantity_milli:'2000',existing_movement_id:42,stock_item_id:1,warehouse_id:11,source_version:1}}));
|
||||
assert(html.includes('8,000 → 8,000'));assert(html.includes('Nova saída: 0,000'));assert(html.includes('Ligar entrega #42: 2,000, sem novo abatimento.'));
|
||||
const source=await fs.readFile('resources/js/WorkshopStockWorkspace.jsx','utf8');
|
||||
assert(source.includes('required defaultValue=""'));assert(source.includes('Seleccione individualmente'));assert(source.includes('/api/workshop-stock/prepare-delivery'));
|
||||
await fs.writeFile('docs/proofs/workshop-delivery-ui-20261102.json',JSON.stringify({kind:'static_impact_render_and_source_checks',zero_new_physical_effect_visible:true,existing_movement_and_quantity_visible:true,explicit_treatment_selection_in_source:true,component_sha256:crypto.createHash('sha256').update(source).digest('hex'),backend_requests:0,visual_review:false,browser_interactions_tested:false},null,2)+'\n');
|
||||
console.log('Delivery impact rendering and explicit treatment selection checks passed; visual review pending.');
|
||||
} finally {await server.close();}
|
||||
@@ -0,0 +1,12 @@
|
||||
import assert from 'node:assert/strict';import fs from 'node:fs/promises';import crypto from 'node:crypto';
|
||||
import React from 'react';import {renderToStaticMarkup} from 'react-dom/server';import {createServer} from 'vite';
|
||||
const server=await createServer({configFile:false,server:{middlewareMode:true,watch:null},appType:'custom',optimizeDeps:{noDiscovery:true}});
|
||||
try {
|
||||
const {WorkshopStockImpact}=await server.ssrLoadModule('/resources/js/WorkshopStockWorkspace.jsx');
|
||||
const base={physical_before:'5000',physical_after:'5000',quantity_milli:'0',linked_quantity_milli:'2000',unallocated_before:'5000',unallocated_after:'3000',existing_movement_id:42,stock_item_id:1,warehouse_id:11,source_version:1};
|
||||
const render=impact=>renderToStaticMarkup(React.createElement(WorkshopStockImpact,{impact}));
|
||||
const allocation=render({...base,mode:'allocate_existing_delivery'});assert(allocation.includes('5,000 → 5,000'));assert(allocation.includes('Quantidade não associada: 5,000 → 3,000'));
|
||||
const returned=render({...base,mode:'return_existing_delivery',physical_after:'8000',return_quantity_milli:'3000',unallocated_before:'3000',unallocated_after:'0'});assert(returned.includes('5,000 → 8,000'));assert(returned.includes('Entrada por devolução: 3,000, da entrega #42'));
|
||||
const source=await fs.readFile('resources/js/WorkshopStockWorkspace.jsx','utf8');assert(source.includes('name="received_in_warehouse" required'));assert(source.includes('required defaultValue=""'));assert(source.includes('proposal_page=${proposalPage}&status=${status}'));assert(source.includes('Confirmo a recepção física'));
|
||||
await fs.writeFile('docs/proofs/workshop-material-cycle-ui-20261102.json',JSON.stringify({kind:'static_impact_render_and_source_checks',allocation_zero_effect:true,remaining_quantity_visible:true,return_positive_effect_visible:true,physical_receiving_checkbox_required_in_source:true,explicit_treatment_choice_in_source:true,history_controls_and_query_in_source:true,source_sha256:crypto.createHash('sha256').update(source).digest('hex'),backend_requests:0,browser_interactions_tested:false,visual_review:false},null,2)+'\n');console.log('Material cycle impact render and source checks passed; visual review pending.');
|
||||
} finally {await server.close();}
|
||||
@@ -0,0 +1,30 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import fs from 'node:fs/promises';
|
||||
import crypto from 'node:crypto';
|
||||
import React from 'react';
|
||||
import {renderToStaticMarkup} from 'react-dom/server';
|
||||
import {createServer} from 'vite';
|
||||
|
||||
const server=await createServer({configFile:false,server:{middlewareMode:true,watch:null},appType:'custom',optimizeDeps:{noDiscovery:true}});
|
||||
try {
|
||||
const {WorkshopStockClosureImpact,WorkshopStockImpact}=await server.ssrLoadModule('/resources/js/WorkshopStockWorkspace.jsx');
|
||||
for(const action of ['withdraw','reject']){
|
||||
const html=renderToStaticMarkup(React.createElement(WorkshopStockClosureImpact,{action}));
|
||||
assert(html.includes(action==='withdraw'?'retirada':'recusada'));
|
||||
assert(html.includes('Efeito físico: 0'));
|
||||
assert(html.includes('nenhuma origem será associada ou libertada'));
|
||||
assert(html.includes('nova conferência'));
|
||||
}
|
||||
const impact={physical_before:'5000',physical_after:'8000',quantity_milli:'0',return_quantity_milli:'3000',mode:'return_existing_delivery',existing_movement_id:42,unallocated_before:'3000',unallocated_after:'0',stock_item_id:1,warehouse_id:11,source_version:1};
|
||||
const html=renderToStaticMarkup(React.createElement(WorkshopStockImpact,{impact}));
|
||||
assert(html.includes('5,000 → 8,000'));assert(html.includes('Entrada por devolução: 3,000'));
|
||||
const source=await fs.readFile('resources/js/WorkshopStockWorkspace.jsx','utf8');
|
||||
assert(source.includes('Prévia arquivada, não executada.'));
|
||||
assert(source.includes('state_version:p.state_version,source_hash:p.source_hash'));
|
||||
assert(source.includes('p.can_withdraw&&can(\'workshop_stock.prepare\',unit)'));
|
||||
assert(source.includes('p.can_review&&can(\'workshop_stock.confirm\',unit)'));
|
||||
assert(source.includes('if(n===generation.current)'));
|
||||
const report={kind:'static_closure_impact_render_and_source_guards',closure_zero_effect_visible:true,archival_and_new_review_visible:true,existing_return_impact_preserved:true,version_and_hash_sent:true,independent_action_visibility_guards:true,context_generation_guards:true,source_sha256:crypto.createHash('sha256').update(source).digest('hex'),backend_requests:0,browser_interactions_tested:false,visual_review:false};
|
||||
await fs.writeFile('docs/proofs/workshop-stock-closure-ui-20261102.json',JSON.stringify(report,null,2)+'\n');
|
||||
console.log(JSON.stringify(report));
|
||||
} finally {await server.close();}
|
||||
@@ -0,0 +1,7 @@
|
||||
import fs from 'node:fs';import path from 'node:path';import {spawnSync} from 'node:child_process';
|
||||
const s=JSON.parse(fs.readFileSync('C:/laragon/data/sgf-tempo/run/modular-regression-current.json','utf8'));
|
||||
if(!/^[a-f0-9]{12}$/.test(s.id)||path.resolve(s.root)!==path.resolve('C:/laragon/data/sgf-tempo/run/modular-regression-'+s.id)||s.port!==55439)throw Error('Wrong QA host');
|
||||
const output=path.join(s.root,'workshop-stock-'+Date.now()+'.xml');
|
||||
const filter=process.argv[2]?['--filter',process.argv[2]]:[];
|
||||
const r=spawnSync('C:/laragon/tools/sgf-tempo/php-8.4.27/php.exe',['vendor/phpunit/phpunit/phpunit','tests/Feature/WorkshopStockTest.php',...filter,'--log-junit',output],{cwd:s.app,stdio:'inherit',windowsHide:true,env:{...process.env,SGF_ISOLATED_REGRESSION_RUN:s.id}});
|
||||
console.log(JSON.stringify({exit_code:r.status,junit:output}));process.exitCode=r.status??1;
|
||||
@@ -0,0 +1,17 @@
|
||||
<?php
|
||||
putenv('APP_ENV=testing');
|
||||
$s=json_decode(file_get_contents('C:/laragon/data/sgf-tempo/run/modular-regression-current.json'),true,512,JSON_THROW_ON_ERROR);
|
||||
if(!preg_match('/^[a-f0-9]{12}$/D',$s['id'])||$s['port']!==55439)throw new RuntimeException('Wrong QA host');
|
||||
require $s['app'].'/vendor/autoload.php';$app=require $s['app'].'/bootstrap/app.php';$app->make(Illuminate\Contracts\Console\Kernel::class)->bootstrap();
|
||||
App\Shared\Testing\TestingDatabaseGuard::assertSafe();
|
||||
$db=Illuminate\Support\Facades\DB::connection();$db->beginTransaction();
|
||||
try {
|
||||
$db->statement('SET TRANSACTION READ ONLY');
|
||||
$i=$db->selectOne("SELECT current_database() AS db,current_user AS role,inet_server_port() AS port,current_setting('cluster_name') AS cluster");
|
||||
if($i->db!=='sgf_tempo_test'||$i->role!=='sgf_test_runner'||(int)$i->port!==55439||$i->cluster!=='sgf_modular_regression_'.$s['id'])throw new RuntimeException('Wrong effective QA host');
|
||||
$grants=$db->table('permission_grants')->where(fn($q)=>$q->where('permission','stock.view')->orWhere('permission','like','workshop_stock.%'))->count();
|
||||
$constraint=$db->selectOne("SELECT pg_get_constraintdef(oid) AS d FROM pg_constraint WHERE conrelid='permission_grants'::regclass AND conname='permission_grants_scope_check'")->d;
|
||||
if($grants!==0||hash('sha256',$constraint)!=='40046092cba51f1b46d26d5f0da8af47e8336e90a8d50b8de3a8c390758f1783')throw new RuntimeException('Temporary rights not restored');
|
||||
$bindings=count(config('workshop_stock.synthetic_sources',[]));if($bindings!==0)throw new RuntimeException('Operational source unexpectedly configured');
|
||||
echo json_encode(['qa_identity'=>$i,'temporary_grants_remaining'=>$grants,'constraint_sha256'=>hash('sha256',$constraint),'operational_workshop_stock_bindings'=>$bindings,'read_only'=>true],JSON_PRETTY_PRINT).PHP_EOL;
|
||||
} finally {$db->rollBack();}
|
||||
@@ -0,0 +1,20 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import fs from 'node:fs/promises';
|
||||
import React from 'react';
|
||||
import {renderToStaticMarkup} from 'react-dom/server';
|
||||
import {createServer} from 'vite';
|
||||
const server=await createServer({configFile:false,server:{middlewareMode:true,watch:null},appType:'custom',optimizeDeps:{noDiscovery:true}});
|
||||
try {
|
||||
const {default:Workspace}=await server.ssrLoadModule('/resources/js/WorkshopMenuWorkspace.jsx');
|
||||
const context={token:'synthetic',company:{id:1,name:'Synthetic'},unit:{id:7,name:'Synthetic'}};
|
||||
const render=can=>renderToStaticMarkup(React.createElement(Workspace,{section:'workshop_stock',context,can,navigate:()=>{},api:()=>{throw Error('SSR must not fetch');}}));
|
||||
const denied=render(p=>p==='company.view'||p==='workshop.view');
|
||||
assert(!denied.includes('Conferência de consumo de material'));
|
||||
const allowed=render((p,u)=>u===7&&['workshop.view','workshop_stock.view'].includes(p));
|
||||
assert(allowed.includes('Conferência de consumo de material'));
|
||||
assert(allowed.includes('A consultar origens autorizadas'));
|
||||
assert(!allowed.includes('Capacidade ainda não disponível'));
|
||||
assert(!allowed.includes('value="0"'));
|
||||
await fs.writeFile('docs/proofs/workshop-stock-ui-20261102.json',JSON.stringify({kind:'static_render',permission_separation:true,pending_notice_removed_for_authorized_view:true,loading_not_zero:true,backend_requests:0,visual_review:false},null,2)+'\n');
|
||||
console.log('Workshop stock static rendering passed. Visual review pending.');
|
||||
} finally {await server.close();}
|
||||
Reference in New Issue
Block a user