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 */ 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.' ); } } }