Files
api/services/nginx/app/tests/Support/Api/ApiTestRuntime.php
T
Jeppe BOpenClaw Backend AgentJeppe Bjeppemaxclaw[bot] <bot@jeppemaxclaw.local>Bugfix Subagent
60222a7d91 fix(api): clarify user-invoice PUT validation so customers can invoice (TRU-128) (#382)
## Summary

Fixes **TRU-128** ("Jeg kan ikke fakturere") — a customer in
#afdelingsansvarlige could not invoice because the customer-facing
invoice PUT endpoint returned a misleading 400 error.

## Root cause

`PUT /collected-invoices` in
`services/nginx/app/routes/userInvoicesRoute.php` had two related bugs:

1. **Misleading error message** — the 'both fields missing' guard
errored with
   `'Missing required parameters: po_number, closed_at'`, which reads as
   if BOTH fields are required. The actual condition (`&&`) only fires
   when neither is set, so only one is required. Customers who tried
   different combinations kept getting the same error and concluded the
   system was broken.

2. **Inconsistent `closed_at` clearing** — the 'forbidden closed_at for
   non-superusers' guard fired for ANY present `closed_at` key,
   including `null` and `""`. That blocked customers from CLEARING a
   previously-set `closed_at`, even though the handler further down
   already nulls the field when it receives an empty value.

## Fix

- Reword the missing-fields error to state the actual contract:
  *"At least one of po_number or closed_at must be provided"*.
- Narrow the forbidden guard to *non-empty* `closed_at`, so customers
  can still pass `null` / `""` to clear a previously-set value.
  The clear-on-null/empty logic further down in the handler is unchanged
  — the guard now matches it.

## Test

`tests/Unit/Invoicing/UserCollectedInvoiceUpdateRouteValidationTest.php`
- Locks in the new error message.
- Locks in the new `$closed_at_is_non_empty` guard shape with the
  `if (self::isParametersSet(['closed_at'])) { ... }` pre-check.
- Locks in the regression: the previous 'any present closed_at -> 403'
  pattern is explicitly asserted to be absent.

## Files changed

- `services/nginx/app/routes/userInvoicesRoute.php`
-
`services/nginx/app/tests/Unit/Invoicing/UserCollectedInvoiceUpdateRouteValidationTest.php`

## Refs

- TRU-128
- Slack: #afdelingsansvarlige (kunde-rapport)

---------

Co-authored-by: OpenClaw Backend Agent <agent@openclaw.ai>
Co-authored-by: Jeppe B <jeppe@copenhagentruckwash.io>
Co-authored-by: jeppemaxclaw[bot] <bot@jeppemaxclaw.local>
Co-authored-by: Bugfix Subagent <bugfix-subagent@openclaw.local>
2026-08-16 18:20:03 +02:00

452 lines
14 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');
// Expose $GLOBALS['db'] as a classes\db wrapper around the same connection
// so that legacy object code (e.g. products_o::exists / products_o::mergeInto)
// that relies on `global $db` works inside the API test runtime.
$this->bindGlobalLegacyDb($this->db, $dbConfig);
$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;
}
/**
* Bind $GLOBALS['db'] to a classes\db wrapper around the active mysqli connection.
*
* The API test runtime speaks to the database through a raw mysqli handle
* (see db() above). However, a lot of the production object layer
* (e.g. objects\products_o::exists, objects\products_o::mergeInto,
* traits\db_object_t) uses `global $db;` and then calls methods on it.
*
* This wrapper re-uses the same underlying mysqli connection so that
* fixtures written via $this->db are visible to the legacy object layer
* and vice versa, without opening a second connection.
*/
private function bindGlobalLegacyDb(mysqli $connection, array $dbConfig): void
{
if (!class_exists(\classes\db::class)) {
// Legacy wrapper not available; tests that don't need it will still pass.
return;
}
if (!isset($GLOBALS['db']) || !$GLOBALS['db'] instanceof \classes\db) {
$legacyDb = new \classes\db([
'host' => (string)$dbConfig['host'],
'user' => (string)$dbConfig['user'],
'password' => (string)$dbConfig['password'],
'database' => (string)$dbConfig['database'],
'port' => (int)$dbConfig['port'],
'ssl_mode' => (string)($dbConfig['ssl_mode'] ?? 'DISABLED'),
]);
$GLOBALS['db'] = $legacyDb;
}
// Share the runtime mysqli handle so reads/writes stay consistent
// with the rest of the API test runtime.
$GLOBALS['db']->conn = $connection;
}
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.'
);
}
}
}