Files
api/services/nginx/app/tests/Support/Api/ApiTestRuntime.php
T
Jeppe Bundgaard 6d4066be1c Add unit tests for InvoicingPeriodDraftOverlay and reference suggestion logic, including fake DB integration and aggregation methods
- Implemented `InvoicingPeriodDraftOverlayTest` with coverage for blocking and permitting invoicing actions based on draft states, transactions, and metadata.
- Created `ReferenceSuggestionsApiTest` to validate ranked and filtered suggestions across bookings, orders, and vehicles with varied match relevance, context, and frequency.
- Added `order_reference_suggestions_service` class, including query methods, normalization utilities, and aggregation logic for reference suggestions.
- Enhanced query handling in `InvoicingPeriodDraftOverlayFakeDb` to validate SQL constraints and column cache resets in overlapping invoicing contexts.
2026-05-11 18:18:08 +02:00

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