411 lines
12 KiB
PHP
411 lines
12 KiB
PHP
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
namespace Tests\Support\Api;
|
|
|
|
use mysqli;
|
|
use Predis\Client as PredisClient;
|
|
use RuntimeException;
|
|
use Throwable;
|
|
|
|
final class ApiTestRuntime
|
|
{
|
|
private const DEFAULT_BASE_URL = 'http://127.0.0.1:18080';
|
|
|
|
private static ?self $instance = null;
|
|
|
|
private ?mysqli $db = null;
|
|
private ?PredisClient $redis = null;
|
|
private ?ApiServer $server = null;
|
|
private ?ApiFixtures $fixtures = null;
|
|
private ?ApiCleanup $cleanup = null;
|
|
private bool $bootstrapped = false;
|
|
private bool $schemaBootstrapped = false;
|
|
private bool $shutdownRegistered = false;
|
|
private string $baseUrl = self::DEFAULT_BASE_URL;
|
|
private bool $usesExternalBaseUrl = false;
|
|
private ?int $internalServerPort = null;
|
|
|
|
public static function instance(): self
|
|
{
|
|
return self::$instance ??= new self();
|
|
}
|
|
|
|
public function skipReason(): ?string
|
|
{
|
|
if (!api_tests_enabled()) {
|
|
return 'API tests are disabled. Run with RUN_API_TESTS=1.';
|
|
}
|
|
|
|
$this->assertApiDatabaseTargetIsSafe();
|
|
|
|
try {
|
|
$this->bootstrapEnvironment();
|
|
$this->bootstrapSchemaIfRequested();
|
|
} catch (Throwable $throwable) {
|
|
return $throwable->getMessage();
|
|
}
|
|
|
|
$missingTables = $this->missingTables();
|
|
if ($missingTables !== []) {
|
|
return 'API tests require an initialized schema. Missing tables: ' . implode(', ', $missingTables);
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
public function beginTest(): void
|
|
{
|
|
$this->bootstrapEnvironment();
|
|
$this->bootstrapSchemaIfRequested();
|
|
$this->cleanup = new ApiCleanup();
|
|
$this->fixtures = new ApiFixtures($this->db(), $this->redis(), $this->cleanup);
|
|
}
|
|
|
|
public function endTest(): void
|
|
{
|
|
if ($this->cleanup !== null) {
|
|
$this->cleanup->run();
|
|
$this->cleanup = null;
|
|
}
|
|
|
|
$this->fixtures = null;
|
|
}
|
|
|
|
public function client(): ApiClient
|
|
{
|
|
$this->bootstrapEnvironment();
|
|
$this->ensureServerIsRunning();
|
|
|
|
return new ApiClient($this->baseUrl);
|
|
}
|
|
|
|
public function restartServer(): void
|
|
{
|
|
if ($this->usesExternalBaseUrl) {
|
|
return;
|
|
}
|
|
|
|
if ($this->server !== null) {
|
|
$this->server->stop();
|
|
$this->server = null;
|
|
}
|
|
|
|
$this->baseUrl = self::DEFAULT_BASE_URL;
|
|
$this->internalServerPort = null;
|
|
}
|
|
|
|
public function fixtures(): ApiFixtures
|
|
{
|
|
if ($this->fixtures === null) {
|
|
throw new RuntimeException('API fixtures are only available during an active API test.');
|
|
}
|
|
|
|
return $this->fixtures;
|
|
}
|
|
|
|
public function db(): mysqli
|
|
{
|
|
if ($this->db === null) {
|
|
throw new RuntimeException('The API test database connection has not been initialized.');
|
|
}
|
|
|
|
return $this->db;
|
|
}
|
|
|
|
public function redis(): ?PredisClient
|
|
{
|
|
return $this->redis;
|
|
}
|
|
|
|
public function queryOne(string $sql): ?array
|
|
{
|
|
$result = $this->db()->query($sql);
|
|
if ($result === false) {
|
|
throw new RuntimeException('Query failed: ' . $sql);
|
|
}
|
|
|
|
$row = $result->fetch_assoc();
|
|
$result->free();
|
|
|
|
return $row ?: null;
|
|
}
|
|
|
|
public function shutdown(): void
|
|
{
|
|
if ($this->server !== null) {
|
|
$this->server->stop();
|
|
$this->server = null;
|
|
}
|
|
|
|
if ($this->db !== null) {
|
|
$this->db->close();
|
|
$this->db = null;
|
|
}
|
|
|
|
if ($this->redis !== null) {
|
|
try {
|
|
$this->redis->disconnect();
|
|
} catch (Throwable) {
|
|
}
|
|
$this->redis = null;
|
|
}
|
|
|
|
$this->bootstrapped = false;
|
|
$this->schemaBootstrapped = false;
|
|
$this->internalServerPort = null;
|
|
}
|
|
|
|
private function bootstrapEnvironment(): void
|
|
{
|
|
if ($this->bootstrapped) {
|
|
return;
|
|
}
|
|
|
|
$dbConfig = $this->readDbConfig();
|
|
mysqli_report(MYSQLI_REPORT_ERROR | MYSQLI_REPORT_STRICT);
|
|
|
|
$this->db = new mysqli(
|
|
$dbConfig['host'],
|
|
$dbConfig['user'],
|
|
$dbConfig['password'],
|
|
$dbConfig['database'],
|
|
$dbConfig['port']
|
|
);
|
|
$this->db->set_charset('utf8mb4');
|
|
|
|
$redisConfig = $this->readRedisConfig();
|
|
if ($redisConfig !== null) {
|
|
$parameters = [
|
|
'scheme' => 'tcp',
|
|
'host' => $redisConfig['host'],
|
|
'port' => $redisConfig['port'],
|
|
'database' => $redisConfig['database'],
|
|
'password' => $redisConfig['password'],
|
|
];
|
|
if ($redisConfig['user'] !== '') {
|
|
$parameters['username'] = $redisConfig['user'];
|
|
}
|
|
$this->redis = new PredisClient($parameters);
|
|
}
|
|
|
|
$configuredBaseUrl = trim((string)getenv('API_TEST_BASE_URL'));
|
|
if ($configuredBaseUrl !== '') {
|
|
$this->baseUrl = rtrim($configuredBaseUrl, '/');
|
|
$this->usesExternalBaseUrl = true;
|
|
}
|
|
|
|
if (!$this->shutdownRegistered) {
|
|
register_shutdown_function([$this, 'shutdown']);
|
|
$this->shutdownRegistered = true;
|
|
}
|
|
|
|
$this->bootstrapped = true;
|
|
}
|
|
|
|
private function bootstrapSchemaIfRequested(): void
|
|
{
|
|
if ($this->schemaBootstrapped) {
|
|
return;
|
|
}
|
|
|
|
if (getenv('API_TEST_BOOTSTRAP_SCHEMA') !== '1') {
|
|
return;
|
|
}
|
|
|
|
(new ApiSchemaBootstrap($this->db()))->ensureSchema();
|
|
$this->schemaBootstrapped = true;
|
|
}
|
|
|
|
private function ensureServerIsRunning(): void
|
|
{
|
|
if ($this->usesExternalBaseUrl) {
|
|
return;
|
|
}
|
|
|
|
if ($this->server !== null && $this->server->isRunning()) {
|
|
return;
|
|
}
|
|
|
|
$url = parse_url($this->baseUrl);
|
|
$host = (string)($url['host'] ?? '127.0.0.1');
|
|
$port = $this->internalServerPort ?? ApiServer::findAvailablePort($host);
|
|
$this->internalServerPort = $port;
|
|
$this->baseUrl = sprintf('http://%s:%d', $host, $port);
|
|
|
|
$this->server = new ApiServer(app_path(), $host, $port);
|
|
$this->server->start();
|
|
$this->waitForPing();
|
|
}
|
|
|
|
private function waitForPing(): void
|
|
{
|
|
$deadline = microtime(true) + 10;
|
|
$client = new ApiClient($this->baseUrl);
|
|
$lastError = 'Timed out waiting for /ping.';
|
|
|
|
while (microtime(true) < $deadline) {
|
|
try {
|
|
$response = $client->get('/ping');
|
|
if ($response->status === 200) {
|
|
return;
|
|
}
|
|
$lastError = 'Unexpected /ping status: ' . $response->status;
|
|
} catch (Throwable $throwable) {
|
|
$lastError = $throwable->getMessage();
|
|
}
|
|
|
|
usleep(200000);
|
|
}
|
|
|
|
throw new RuntimeException($this->server?->describeFailure($lastError) ?? $lastError);
|
|
}
|
|
|
|
/**
|
|
* @return array<int, string>
|
|
*/
|
|
private function missingTables(): array
|
|
{
|
|
$requiredTables = [
|
|
'users',
|
|
'groups',
|
|
'groups_permissions',
|
|
'departments',
|
|
'department_categories',
|
|
'categories',
|
|
'orders',
|
|
'collected_order_invoices',
|
|
'tokens',
|
|
'subusers',
|
|
'subuser_grants',
|
|
'module_config',
|
|
'customer_attributes',
|
|
];
|
|
|
|
$missing = [];
|
|
foreach ($requiredTables as $table) {
|
|
$escaped = $this->db()->real_escape_string($table);
|
|
$result = $this->db()->query("SHOW TABLES LIKE '{$escaped}'");
|
|
if ($result === false || $result->num_rows === 0) {
|
|
$missing[] = $table;
|
|
}
|
|
if ($result !== false) {
|
|
$result->free();
|
|
}
|
|
}
|
|
|
|
return $missing;
|
|
}
|
|
|
|
/**
|
|
* @return array{host:string,user:string,password:string,database:string,port:int}
|
|
*/
|
|
private function readDbConfig(): array
|
|
{
|
|
$target = strtolower(trim((string)(getenv('CONFIG_DB_TARGET') ?: 'live')));
|
|
if ($target !== 'debug') {
|
|
$target = 'live';
|
|
}
|
|
|
|
$host = $this->readConfigValue('CONFIG_DB_HOST', 'CONFIG_DB_DEBUG_HOST', $target);
|
|
$user = $this->readConfigValue('CONFIG_DB_USER', 'CONFIG_DB_DEBUG_USER', $target);
|
|
$password = $this->readConfigValue('CONFIG_DB_PASSWORD', 'CONFIG_DB_DEBUG_PASSWORD', $target);
|
|
$database = $this->readConfigValue('CONFIG_DB_DATABASE', 'CONFIG_DB_DEBUG_DATABASE', $target);
|
|
$port = (int)($this->readConfigValue('CONFIG_DB_PORT', 'CONFIG_DB_DEBUG_PORT', $target) ?: '3306');
|
|
|
|
$this->assertApiDatabaseTargetIsSafe($target, $host, $user, $database, $port);
|
|
|
|
if ($host === '' || $user === '' || $database === '') {
|
|
throw new RuntimeException('API tests require CONFIG_DB_HOST, CONFIG_DB_USER and CONFIG_DB_DATABASE to be set.');
|
|
}
|
|
|
|
return [
|
|
'host' => $host,
|
|
'user' => $user,
|
|
'password' => $password,
|
|
'database' => $database,
|
|
'port' => $port > 0 ? $port : 3306,
|
|
];
|
|
}
|
|
|
|
/**
|
|
* @return array{host:string,user:string,password:string,database:int,port:int}|null
|
|
*/
|
|
private function readRedisConfig(): ?array
|
|
{
|
|
$target = strtolower(trim((string)(getenv('CONFIG_DB_TARGET') ?: 'live')));
|
|
if ($target !== 'debug') {
|
|
$target = 'live';
|
|
}
|
|
|
|
$host = $this->readConfigValue('REDIS_CONFIG_HOST', 'REDIS_CONFIG_DEBUG_HOST', $target);
|
|
if ($host === '') {
|
|
return null;
|
|
}
|
|
|
|
return [
|
|
'host' => $host,
|
|
'user' => $this->readConfigValue('REDIS_CONFIG_USER', 'REDIS_CONFIG_DEBUG_USER', $target),
|
|
'password' => $this->readConfigValue('REDIS_CONFIG_PASSWORD', 'REDIS_CONFIG_DEBUG_PASSWORD', $target),
|
|
'database' => (int)($this->readConfigValue('REDIS_CONFIG_DATABASE', 'REDIS_CONFIG_DEBUG_DATABASE', $target) ?: '0'),
|
|
'port' => (int)($this->readConfigValue('REDIS_CONFIG_PORT', 'REDIS_CONFIG_DEBUG_PORT', $target) ?: '6379'),
|
|
];
|
|
}
|
|
|
|
private function readConfigValue(string $liveKey, string $debugKey, string $target): string
|
|
{
|
|
$liveValue = trim((string)(getenv($liveKey) ?: ''));
|
|
$debugValue = trim((string)(getenv($debugKey) ?: ''));
|
|
|
|
if ($target === 'debug' && $debugValue !== '') {
|
|
return $debugValue;
|
|
}
|
|
|
|
return $liveValue;
|
|
}
|
|
|
|
private function assertApiDatabaseTargetIsSafe(
|
|
?string $target = null,
|
|
?string $host = null,
|
|
?string $user = null,
|
|
?string $database = null,
|
|
?int $port = null
|
|
): void {
|
|
if ((string)(getenv('API_TEST_ALLOW_LIVE_DB') ?: '') === '1') {
|
|
return;
|
|
}
|
|
|
|
$target = strtolower(trim((string)($target ?? (getenv('CONFIG_DB_TARGET') ?: 'live'))));
|
|
if ($target !== 'debug') {
|
|
throw new RuntimeException(
|
|
'Refusing to run API tests against CONFIG_DB_TARGET=live. Use CONFIG_DB_TARGET=debug, or set API_TEST_ALLOW_LIVE_DB=1 for an explicit override.'
|
|
);
|
|
}
|
|
|
|
$host ??= $this->readConfigValue('CONFIG_DB_HOST', 'CONFIG_DB_DEBUG_HOST', $target);
|
|
$user ??= $this->readConfigValue('CONFIG_DB_USER', 'CONFIG_DB_DEBUG_USER', $target);
|
|
$database ??= $this->readConfigValue('CONFIG_DB_DATABASE', 'CONFIG_DB_DEBUG_DATABASE', $target);
|
|
$port ??= (int)($this->readConfigValue('CONFIG_DB_PORT', 'CONFIG_DB_DEBUG_PORT', $target) ?: '3306');
|
|
|
|
$liveHost = trim((string)(getenv('CONFIG_DB_HOST') ?: ''));
|
|
$liveUser = trim((string)(getenv('CONFIG_DB_USER') ?: ''));
|
|
$liveDatabase = trim((string)(getenv('CONFIG_DB_DATABASE') ?: ''));
|
|
$livePort = (int)(trim((string)(getenv('CONFIG_DB_PORT') ?: '3306')) ?: '3306');
|
|
|
|
if (
|
|
$liveHost !== '' &&
|
|
$liveUser !== '' &&
|
|
$liveDatabase !== '' &&
|
|
$host === $liveHost &&
|
|
$user === $liveUser &&
|
|
$database === $liveDatabase &&
|
|
$port === $livePort
|
|
) {
|
|
throw new RuntimeException(
|
|
'Refusing to run API tests because CONFIG_DB_TARGET=debug resolves to the configured live database. Point CONFIG_DB_DEBUG_* at an isolated database, or set API_TEST_ALLOW_LIVE_DB=1 for an explicit override.'
|
|
);
|
|
}
|
|
}
|
|
}
|