Files
compras/tests/Concurrency/run-stock.php
T

216 lines
13 KiB
PHP

<?php
use App\Http\Controllers\StockOperationController;
use App\Models\CriticalityLevel;
use App\Models\Equipment;
use App\Models\EquipmentSparePart;
use App\Models\SparePart;
use App\Models\StockMovement;
use App\Models\StockOperation;
use App\Models\Warehouse;
use App\Services\ReceiptWorkflow;
use App\Services\StockWorkflow;
use Illuminate\Contracts\Console\Kernel;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Artisan;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
use Illuminate\Support\Str;
use Symfony\Component\Process\Process;
require __DIR__.'/../../vendor/autoload.php';
// Process-only settings: the project's .env and live database are never rewritten.
foreach (['APP_ENV' => 'testing', 'CACHE_STORE' => 'array', 'SESSION_DRIVER' => 'array', 'QUEUE_CONNECTION' => 'sync', 'MAIL_MAILER' => 'array'] as $key => $value) {
putenv($key.'='.$value);
$_ENV[$key] = $_SERVER[$key] = $value;
}
$app = require __DIR__.'/../../bootstrap/app.php';
$app->make(Kernel::class)->bootstrap();
require __DIR__.'/../Support/ReceiptFixture.php';
$original = DB::connection();
if ($original->getDriverName() !== 'pgsql') {
throw new LogicException('Este ensaio exige a ligação PostgreSQL local.');
}
$originalDatabase = $original->getDatabaseName();
$effectiveConfig = $original->getConfig();
$admin = $original->getPdo();
$database = 'compras_receipts_test_'.bin2hex(random_bytes(6));
if ($database === $originalDatabase || ! preg_match('/^compras_receipts_test_[a-f0-9]{12}$/', $database)) {
throw new LogicException('Nome de base de testes não autorizado.');
}
$liveUsersBefore = $admin->query('SELECT COUNT(*) FROM users')->fetchColumn();
$created = false;
$processes = [];
$report = ['tested_at_utc' => gmdate('c'), 'database' => $database, 'separate_database' => true, 'transport' => 'Laravel HTTP kernel in independent PHP processes; real PostgreSQL connections', 'scenarios' => []];
try {
$admin->exec('CREATE DATABASE "'.$database.'"');
$created = true;
$isolated = $effectiveConfig;
$isolated['url'] = null;
$isolated['database'] = $database;
// Replace both PostgreSQL aliases in this process and discard any schema facade
// resolved by providers while the live configuration was being read.
config(['database.connections.receipt_concurrency' => $isolated, 'database.connections.pgsql' => $isolated]);
DB::purge('pgsql');
DB::setDefaultConnection('receipt_concurrency');
Schema::clearResolvedInstance('db.schema');
app()->forgetInstance('db.schema');
if (DB::selectOne('select current_database() as name')->name !== $database) {
throw new LogicException('Migração recusada: ligação não isolada.');
}
if (Schema::getConnection()->getDatabaseName() !== $database || DB::connection('pgsql')->getDatabaseName() !== $database) {
throw new LogicException('Migração recusada: esquema ou alias PostgreSQL não isolado.');
}
Artisan::call('migrate', ['--database' => 'receipt_concurrency', '--force' => true]);
// Hydrated models retain their connection name. Use one alias for the entire
// fixture/workflow transaction so FK checks never wait on our other connection.
DB::disconnect('receipt_concurrency');
DB::setDefaultConnection('pgsql');
DB::statement("SET lock_timeout = '25s'");
$workerEnv = [
'APP_ENV' => 'testing', 'DB_CONNECTION' => 'pgsql', 'DB_URL' => '', 'DB_DATABASE' => $database,
'DB_HOST' => $effectiveConfig['host'], 'DB_PORT' => (string) $effectiveConfig['port'],
'DB_USERNAME' => $effectiveConfig['username'], 'DB_PASSWORD' => $effectiveConfig['password'],
'DB_SSLMODE' => $effectiveConfig['sslmode'] ?? 'prefer',
'CACHE_STORE' => 'array', 'SESSION_DRIVER' => 'array', 'QUEUE_CONNECTION' => 'sync', 'MAIL_MAILER' => 'array',
];
foreach (['over_issue', 'same_issue', 'same_transfer', 'opposing_transfers', 'same_adjustment', 'stale_adjustment', 'spare_part_issue'] as $scenario) {
$fixture = receiptFixture();
$equipment = null;
if ($scenario === 'spare_part_issue') {
$level = CriticalityLevel::query()->create(['tenant_id' => $fixture['tenant']->id, 'created_by' => $fixture['receiver']->id, 'code' => 'CRIT-PG', 'name' => 'Critério fictício', 'criteria' => 'Somente para concorrência isolada', 'is_critical' => true, 'created_at' => now()]);
$equipment = Equipment::query()->create(['tenant_id' => $fixture['tenant']->id, 'created_by' => $fixture['receiver']->id, 'department_id' => $fixture['department']->id, 'criticality_level_id' => $level->id, 'code' => 'EQ-PG', 'description' => 'Equipamento fictício', 'area' => 'Linha fictícia', 'status' => 'stopped']);
$part = SparePart::query()->create(['tenant_id' => $fixture['tenant']->id, 'created_by' => $fixture['receiver']->id, 'stock_item_id' => $fixture['stock']->id, 'criticality_level_id' => $level->id, 'minimum_milli' => 1000]);
EquipmentSparePart::query()->create(['tenant_id' => $fixture['tenant']->id, 'created_by' => $fixture['receiver']->id, 'equipment_id' => $equipment->id, 'spare_part_id' => $part->id, 'created_at' => now()]);
}
app(ReceiptWorkflow::class)->confirm($fixture['receiver'], $fixture['order'], receiptPayload($fixture, '1.000'));
$destination = Warehouse::query()->create(['tenant_id' => $fixture['tenant']->id, 'created_by' => $fixture['receiver']->id, 'code' => 'DEST', 'name' => 'Destino fictício']);
$first = ['kind' => 'issue', 'reference' => 'SAI-A', 'idempotency_key' => (string) Str::uuid(), 'warehouse_id' => $fixture['warehouse']->id, 'stock_item_id' => $fixture['stock']->id, 'responsible_id' => $fixture['issuer']->id, 'destination' => 'Produção fictícia', 'reason' => 'Ensaio isolado de concorrência', 'quantity' => '0.600'];
if ($equipment) {
$first['equipment_id'] = $equipment->id;
}
$second = [...$first, 'reference' => 'SAI-B', 'idempotency_key' => (string) Str::uuid()];
if ($scenario === 'same_issue') {
$second = $first;
} elseif (in_array($scenario, ['same_transfer', 'opposing_transfers'])) {
$first['kind'] = 'transfer';
$first['destination_warehouse_id'] = $destination->id;
$second = $first;
if ($scenario === 'opposing_transfers') {
$second['warehouse_id'] = $destination->id;
$second['destination_warehouse_id'] = $fixture['warehouse']->id;
$second['reference'] = 'TR-B';
$second['idempotency_key'] = (string) Str::uuid();
$second['quantity'] = '0.100';
}
} elseif (in_array($scenario, ['same_adjustment', 'stale_adjustment'])) {
$countPayload = [...$first, 'kind' => 'count', 'quantity' => '0.800', 'ledger_version' => (int) StockMovement::query()->max('id')];
// Use the HTTP validation/canonicalisation path for the count as well.
$request = Request::create('/stock/operacoes', 'POST', $countPayload, server: ['HTTP_ACCEPT' => 'application/json']);
$request->setUserResolver(fn () => $fixture['receiver']);
$response = app(StockOperationController::class)->store($request, app(StockWorkflow::class));
$countId = json_decode($response->getContent(), true)['id'];
$first = [...$first, 'kind' => 'adjustment', 'reference' => 'AJ-A', 'count_id' => $countId, 'idempotency_key' => (string) Str::uuid()];
$second = $first;
if ($scenario === 'stale_adjustment') {
$issue = [...$countPayload, 'kind' => 'issue', 'reference' => 'SAI-BEFORE', 'idempotency_key' => (string) Str::uuid(), 'quantity' => '0.100'];
$request = Request::create('/stock/operacoes', 'POST', $issue, server: ['HTTP_ACCEPT' => 'application/json']);
$request->setUserResolver(fn () => $fixture['receiver']);
app(StockOperationController::class)->store($request, app(StockWorkflow::class));
}
}
$connection = DB::connection();
$connection->beginTransaction();
$connection->table('tenants')->where('id', $fixture['tenant']->id)->lockForUpdate()->first();
$processes = [];
foreach ([$first, $second] as $index => $payload) {
$label = $index === 0 ? 'A' : 'B';
$user = $index === 0 ? $fixture['receiver'] : $fixture['otherReceiver'];
$process = new Process([PHP_BINARY, __DIR__.'/stock-worker.php', $database, (string) $user->id, '0', $label], base_path(), $workerEnv);
$process->setInput(json_encode($payload, JSON_THROW_ON_ERROR));
$process->setTimeout(45);
$process->start();
$processes[] = $process;
}
$statement = $admin->prepare("SELECT count(*) FROM pg_stat_activity WHERE datname = ? AND application_name IN ('procura_stock_A','procura_stock_B') AND wait_event_type = 'Lock'");
$deadline = microtime(true) + 20;
$waits = 0;
do {
$statement->execute([$database]);
$waits = (int) $statement->fetchColumn();
if ($waits >= 2) {
break;
}
foreach ($processes as $process) {
if (! $process->isRunning()) {
throw new RuntimeException('Worker terminou antes da sobreposição: '.$process->getOutput().' '.$process->getErrorOutput());
}
}
usleep(20000);
} while (microtime(true) < $deadline);
$connection->commit();
if ($waits < 2) {
throw new RuntimeException('Sobreposição de bloqueios não demonstrada.');
}
$results = [];
foreach ($processes as $process) {
$process->wait();
if (! $process->isSuccessful()) {
throw new RuntimeException($process->getErrorOutput());
}$results[] = json_decode($process->getOutput(), true, 512, JSON_THROW_ON_ERROR);
}
$statuses = array_column($results, 'status');
sort($statuses);
$expected = match ($scenario) {
'over_issue', 'spare_part_issue' => [201, 422],'stale_adjustment' => [422, 422],'opposing_transfers' => null,default => [200, 201]
};
if (($expected !== null && $statuses !== $expected) || count(array_unique(array_column($results, 'backend_pid'))) !== 2) {
throw new RuntimeException('Respostas inesperadas: '.json_encode($results));
}
$source = app(StockWorkflow::class)->balance($fixture['stock']->id, $fixture['warehouse']->id)['quantity'];
$target = app(StockWorkflow::class)->balance($fixture['stock']->id, $destination->id)['quantity'];
$expectedSource = match ($scenario) {
'same_adjustment' => 800,'stale_adjustment' => 900,default => 400
};
if ($scenario === 'opposing_transfers') {
if (! in_array($statuses, [[201, 201], [201, 422]], true) || $source + $target !== 1000 || $source < 0 || $target < 0) {
throw new RuntimeException('Transferências opostas inválidas.');
}
} elseif ($source !== $expectedSource || $target !== ($scenario === 'same_transfer' ? 600 : 0)) {
throw new RuntimeException('Saldos inesperados.');
}
foreach (StockOperation::query()->where('kind', 'transfer')->get() as $transfer) {
$moves = StockMovement::query()->where('stock_operation_id', $transfer->id);
if ($moves->count() !== 2 || (int) $moves->sum('quantity_milli') !== 0) {
throw new RuntimeException('Transferência incompleta.');
}
}
$report['scenarios'][] = ['name' => $scenario, 'passed' => true, 'overlapping_lock_waits' => $waits, 'responses' => $results, 'source_milli' => $source, 'destination_milli' => $target];
}
$report['passed'] = true;
} catch (Throwable $error) {
$report['passed'] = false;
$report['failure'] = $error->getMessage();
} finally {
foreach ($processes as $process) {
if ($process->isRunning()) {
$process->stop();
}
}
if (isset($connection) && $connection->transactionLevel() > 0) {
$connection->rollBack();
}
DB::disconnect('receipt_concurrency');
DB::disconnect('pgsql');
if ($created && preg_match('/^compras_receipts_test_[a-f0-9]{12}$/', $database) && $database !== $originalDatabase) {
$admin->exec('DROP DATABASE "'.$database.'"');
$report['test_database_dropped'] = true;
}
$report['live_user_count_unchanged'] = $admin->query('SELECT COUNT(*) FROM users')->fetchColumn() === $liveUsersBefore;
}
file_put_contents(__DIR__.'/../../docs/stock-concorrencia-postgresql.json', json_encode($report, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE | JSON_THROW_ON_ERROR).PHP_EOL);
echo json_encode($report, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE | JSON_THROW_ON_ERROR).PHP_EOL;
exit(($report['passed'] ?? false) ? 0 : 1);