mirror of
https://github.com/Vateron-Media/XC_VM.git
synced 2026-10-31 11:03:22 +01:00
Leads of the 2026-10-10 scan, re-read and confirmed: - transactional() over a DDL statement: the statement commits on its own, PDO then refused the commit and the rollback, so a module migration that had done its work threw "There is no active transaction", the callback's own error was hidden, and the handler stayed "in a transaction" for the rest of the process. commit() and rollback() leave the transaction first and ask PDO only for one that is still open. - A module updated at the second attempt stayed Failed, and unloaded. - A module archive is told apart by its content: a stored one is named .zip whatever it is and an upload has no extension, so a tar.gz update could not be extracted again or fetched by a load balancer, and a plain .tar upload failed. - A 404 page, a redirect's body or a page answered 200 was cached under an image's name and never fetched again: only a 2xx answer that is an image is cached. - A prebuffer shorter than a segment returned the whole playlist (array_slice(..., -0)). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
295 lines
9.6 KiB
PHP
295 lines
9.6 KiB
PHP
<?php
|
|
|
|
use XcVm\Core\Database\DatabaseHandler;
|
|
/**
|
|
* TestDb — MariaDB/MySQL test double for the XC_VM Database wrapper.
|
|
*
|
|
* Mirrors the subset of Database's public API used by repositories/services
|
|
* (query / get_rows / get_row / get_col / get_column / num_rows /
|
|
* last_insert_id / escape), over the database engine production runs.
|
|
* Inject via the service's setDb() (the DI seam present on every domain
|
|
* repository/service) or DatabaseFactory::set().
|
|
*
|
|
* Connection: XCVM_TEST_DB_DSN (a PDO MySQL DSN), XCVM_TEST_DB_USER and
|
|
* XCVM_TEST_DB_PASS; unset, root over the local server's socket (a panel
|
|
* host), or without one over 127.0.0.1:3306 (`make test-db`). CI sets its own.
|
|
*
|
|
* Every TestDb gets an empty database of its own: a throwaway schema
|
|
* (`xcvm_t<pid>_<n>`), dropped with the instance. The session runs the
|
|
* sql_mode the installer configures (install: NO_ENGINE_SUBSTITUTION), so
|
|
* tests see the coercions and truncations a live panel sees.
|
|
*
|
|
* Extends DatabaseHandler so it satisfies the setDb(DatabaseHandler) DI seam
|
|
* as a real subtype; the parent constructor is deliberately not invoked —
|
|
* our own constructor wires the connection instead.
|
|
*
|
|
* @package XC_VM_Tests_Support
|
|
*/
|
|
final class TestDb extends DatabaseHandler {
|
|
|
|
/** sql_mode of a live panel: the installer writes it to mariadb.cnf. */
|
|
public const SQL_MODE = 'NO_ENGINE_SUBSTITUTION';
|
|
|
|
/** Without the variables: root over the local server's socket (a panel host), else `make test-db`'s port. */
|
|
private const SOCKET = '/run/mysqld/mysqld.sock';
|
|
|
|
public PDO $pdo;
|
|
|
|
/** @var array<int,array<string,mixed>> Buffered rows from the last SELECT. */
|
|
private array $rows = [];
|
|
|
|
/** @var int Rows the last statement returned (a SELECT) or changed (a write), as PDOStatement::rowCount() in Database::num_rows(). */
|
|
private int $count = 0;
|
|
|
|
private int $lastInsertId = 0;
|
|
|
|
/** This instance's throwaway schema. */
|
|
private string $schema;
|
|
|
|
private static int $schemas = 0;
|
|
|
|
/** The schema is several processes' ($rShared): no instance drops it. */
|
|
private bool $shared = false;
|
|
|
|
/**
|
|
* $rShared names one schema several processes share, created when missing
|
|
* and never dropped by an instance: XC_VM_Fanout's interop harness runs
|
|
* MAIN's PHP as many processes over one database. Named
|
|
* `xcvm_t<pid>_<n>` after a process that outlives them (the Go test), the
|
|
* first TestDb after that process ends drops it as an orphan.
|
|
*/
|
|
public function __construct(?string $rShared = null) {
|
|
$this->pdo = self::connect();
|
|
if ($rShared !== null) {
|
|
if (!preg_match('/^xcvm_t\d+_\d+$/', $rShared)) {
|
|
throw new InvalidArgumentException('A shared test schema is named xcvm_t<pid>_<n>: ' . $rShared);
|
|
}
|
|
$this->schema = $rShared;
|
|
$this->shared = true;
|
|
$this->pdo->exec('CREATE DATABASE IF NOT EXISTS `' . $rShared . '`');
|
|
$this->pdo->exec('USE `' . $rShared . '`');
|
|
return;
|
|
}
|
|
if (self::$schemas === 0) {
|
|
self::dropOrphanSchemas($this->pdo);
|
|
}
|
|
$this->schema = 'xcvm_t' . getmypid() . '_' . ++self::$schemas;
|
|
$this->pdo->exec('CREATE DATABASE `' . $this->schema . '`');
|
|
$this->pdo->exec('USE `' . $this->schema . '`');
|
|
}
|
|
|
|
public function __destruct() {
|
|
if (!$this->shared) {
|
|
$this->pdo->exec('DROP DATABASE IF EXISTS `' . $this->schema . '`');
|
|
}
|
|
}
|
|
|
|
/** A new connection to the test server, in production's sql_mode; $rSchema selects a database. */
|
|
public static function connect(?string $rSchema = null): PDO {
|
|
['XCVM_TEST_DB_DSN' => $rDsn, 'XCVM_TEST_DB_USER' => $rUser] = self::env();
|
|
if ($rSchema !== null) {
|
|
$rDsn = preg_replace('/;?dbname=[^;]*/', '', $rDsn) . ';dbname=' . $rSchema;
|
|
}
|
|
// An explicit connect timeout: a boot under test (AdminGlobalsStage) can leave
|
|
// default_socket_timeout at 0, which mysqlnd takes for a TCP connect's.
|
|
try {
|
|
$rPdo = new PDO($rDsn, $rUser, self::env()['XCVM_TEST_DB_PASS'] ?? null, [PDO::ATTR_TIMEOUT => 10]);
|
|
} catch (PDOException $e) {
|
|
throw new PDOException($e->getMessage() . ' (no test database at ' . $rDsn . ' as ' . $rUser . ': without a local server `make test-db` starts one; a server that refuses this user needs XCVM_TEST_DB_DSN, XCVM_TEST_DB_USER and XCVM_TEST_DB_PASS, see docs/en/guides/phpunit-phar.md section 2)', (int) $e->getCode(), $e);
|
|
}
|
|
$rPdo->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION);
|
|
$rPdo->exec("SET SESSION sql_mode = '" . self::SQL_MODE . "'");
|
|
return $rPdo;
|
|
}
|
|
|
|
/**
|
|
* The connection variables, defaults filled in: also for a child process
|
|
* started with an environment of its own (proc_open's $env replaces the parent's).
|
|
*
|
|
* @return array{XCVM_TEST_DB_DSN: string, XCVM_TEST_DB_USER: string, XCVM_TEST_DB_PASS?: string}
|
|
*/
|
|
public static function env(): array {
|
|
$rOut = ['XCVM_TEST_DB_DSN' => file_exists(self::SOCKET) ? 'mysql:unix_socket=' . self::SOCKET : 'mysql:host=127.0.0.1;port=3306', 'XCVM_TEST_DB_USER' => 'root'];
|
|
foreach (['XCVM_TEST_DB_DSN', 'XCVM_TEST_DB_USER', 'XCVM_TEST_DB_PASS'] as $rName) {
|
|
if (($rValue = getenv($rName)) !== false && $rValue !== '') {
|
|
$rOut[$rName] = $rValue;
|
|
}
|
|
}
|
|
return $rOut;
|
|
}
|
|
|
|
/** This instance's schema, for a child process that opens its own connection to it (connect()). */
|
|
public function schema(): string {
|
|
return $this->schema;
|
|
}
|
|
|
|
/** Schemas a run left behind when its process died (a segfault skips __destruct). */
|
|
private static function dropOrphanSchemas(PDO $pdo): void {
|
|
foreach ($pdo->query("SHOW DATABASES LIKE 'xcvm\\_t%'")->fetchAll(PDO::FETCH_COLUMN) as $name) {
|
|
if (preg_match('/^xcvm_t(\d+)_\d+$/', $name, $m) && !posix_kill((int) $m[1], 0)) {
|
|
$pdo->exec('DROP DATABASE IF EXISTS `' . $name . '`');
|
|
}
|
|
}
|
|
}
|
|
|
|
/** True when the statement is schema DDL (a test may send several in one string). */
|
|
private static function isDdl(string $sql): bool {
|
|
return (bool) preg_match('/^\s*(CREATE|ALTER|DROP)\s+TABLE/i', $sql);
|
|
}
|
|
|
|
/**
|
|
* Execute raw schema/seed SQL (one or more `;`-separated statements).
|
|
*/
|
|
public function exec(string $sql): void {
|
|
$this->pdo->exec($sql);
|
|
}
|
|
|
|
/**
|
|
* Run a prepared query. Bind values follow $query (as in Database::query()).
|
|
* SELECT/WITH/SHOW results are buffered for get_rows()/get_row()/num_rows().
|
|
*/
|
|
public function query($query, ...$args): bool {
|
|
if (self::isDdl($query)) {
|
|
$this->pdo->exec($query);
|
|
$this->rows = array();
|
|
$this->count = 0;
|
|
return true;
|
|
}
|
|
|
|
// Mirror Database: the literal string 'null' and PHP null bind as SQL NULL.
|
|
$binds = array();
|
|
foreach ($args as $a) {
|
|
$binds[] = (is_string($a) && strtolower($a) === 'null') ? null : $a;
|
|
}
|
|
|
|
$stmt = $this->pdo->prepare($query);
|
|
$stmt->execute($binds);
|
|
|
|
if (preg_match('/^\s*(SELECT|WITH|SHOW)/i', $query)) {
|
|
$this->rows = $stmt->fetchAll(PDO::FETCH_ASSOC) ?: array();
|
|
$this->count = count($this->rows);
|
|
} else {
|
|
$this->rows = array();
|
|
$this->count = $stmt->rowCount();
|
|
$id = $this->pdo->lastInsertId();
|
|
if ($id) {
|
|
$this->lastInsertId = (int) $id;
|
|
}
|
|
}
|
|
|
|
return true;
|
|
}
|
|
|
|
/**
|
|
* Return buffered rows, optionally keyed by a column (mirrors Database).
|
|
*/
|
|
public function get_rows($use_id = false, $column_as_id = '', $unique_row = true, $sub_row_id = '') {
|
|
$rows = array_map(fn($row) => $this->clean_row($row), $this->rows);
|
|
if (!$use_id) {
|
|
return $rows;
|
|
}
|
|
|
|
$out = array();
|
|
foreach ($rows as $row) {
|
|
if ($column_as_id !== '' && array_key_exists($column_as_id, $row)) {
|
|
if ($unique_row) {
|
|
$out[$row[$column_as_id]] = $row;
|
|
} elseif (!empty($sub_row_id) && array_key_exists($sub_row_id, $row)) {
|
|
$out[$row[$column_as_id]][$row[$sub_row_id]] = $row;
|
|
} else {
|
|
$out[$row[$column_as_id]][] = $row;
|
|
}
|
|
} else {
|
|
$out[] = $row;
|
|
}
|
|
}
|
|
return $out;
|
|
}
|
|
|
|
public function get_row() {
|
|
return $this->clean_row($this->rows[0] ?? array());
|
|
}
|
|
|
|
public function get_raw_rows(): array {
|
|
return $this->rows;
|
|
}
|
|
|
|
public function get_raw_row(): ?array {
|
|
return $this->rows[0] ?? null;
|
|
}
|
|
|
|
public function get_col() {
|
|
$row = $this->rows[0] ?? null;
|
|
return $row ? array_values($row)[0] : false;
|
|
}
|
|
|
|
public function get_column(): array {
|
|
$col = array();
|
|
foreach ($this->rows as $row) {
|
|
$col[] = array_values($row)[0] ?? null;
|
|
}
|
|
return $col;
|
|
}
|
|
|
|
/** Rows of the last SELECT, or rows the last write changed (Database::num_rows(), PDOStatement::rowCount()). */
|
|
public function num_rows(): int {
|
|
return $this->count;
|
|
}
|
|
|
|
public function last_insert_id() {
|
|
return $this->lastInsertId;
|
|
}
|
|
|
|
public function escape($string) {
|
|
return $this->pdo->quote((string) $string);
|
|
}
|
|
|
|
public function close_mysql(): bool {
|
|
return true;
|
|
}
|
|
|
|
/** The test connection is always up (DatabaseFactory::connectLazy() asks). */
|
|
public function ping(): bool {
|
|
return true;
|
|
}
|
|
|
|
/** Database's escaping, on the strings a MySQL fetch would return; get_raw_rows() skips it as Database's does. */
|
|
public function clean_row($row) {
|
|
foreach ($row as $key => $value) {
|
|
if (is_string($value) && $value !== '') {
|
|
$row[$key] = self::parseCleanValue($value);
|
|
}
|
|
}
|
|
return $row;
|
|
}
|
|
|
|
/**
|
|
* Transactions over the backing PDO, with DatabaseHandler's semantics: one
|
|
* at a time (a nested begin is refused, false), commit and rollback false
|
|
* outside one.
|
|
*/
|
|
public function beginTransaction() {
|
|
if ($this->inTransaction) {
|
|
return false;
|
|
}
|
|
$this->inTransaction = $this->pdo->beginTransaction();
|
|
return $this->inTransaction;
|
|
}
|
|
|
|
public function commit() {
|
|
if (!$this->inTransaction) {
|
|
return false;
|
|
}
|
|
$this->inTransaction = false;
|
|
return $this->pdo->inTransaction() ? $this->pdo->commit() : true;
|
|
}
|
|
|
|
public function rollback() {
|
|
if (!$this->inTransaction) {
|
|
return false;
|
|
}
|
|
$this->inTransaction = false;
|
|
return $this->pdo->inTransaction() && $this->pdo->rollBack();
|
|
}
|
|
}
|