-
-
Notifications
You must be signed in to change notification settings - Fork 441
test: add database layer unit tests via SQLite + MySQL-shaped PDO decorator #7127
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
TheWitness
merged 5 commits into
Cacti:develop
from
somethingwithproof:feat/db-unit-tests
May 12, 2026
Merged
Changes from all commits
Commits
Show all changes
5 commits
Select commit
Hold shift + click to select a range
5cfd5d0
test: add database layer unit tests via SQLite + MySQL-shaped PDO dec…
somethingwithproof f944f2c
chore(phpstan): catch up baseline with 11 upstream-detected entries
somethingwithproof acbff5d
fix(test): apply Copilot review feedback for DB unit tests
somethingwithproof db699c2
test: add shared unit stubs for DB tests
somethingwithproof 0b11cee
Merge branch 'develop' into feat/db-unit-tests
TheWitness File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,80 @@ | ||
| <?php | ||
| /* | ||
| +-------------------------------------------------------------------------+ | ||
| | Copyright (C) 2004-2026 The Cacti Group | | ||
| | | | ||
| | This program is free software; you can redistribute it and/or | | ||
| | modify it under the terms of the GNU General Public License | | ||
| | as published by the Free Software Foundation; either version 2 | | ||
| | of the License, or (at your option) any later version. | | ||
| +-------------------------------------------------------------------------+ | ||
| | Cacti: The Complete RRDtool-based Graphing Solution | | ||
| +-------------------------------------------------------------------------+ | ||
| */ | ||
|
|
||
| declare(strict_types=1); | ||
|
|
||
| /** | ||
| * Test-only PDO decorator that translates MySQL-specific syntax in | ||
| * lib/database.php queries to a form sqlite::memory: can execute. | ||
| * Only the surface used by the DB unit tests is implemented. | ||
| * | ||
| * The handle uses PDO::ERRMODE_EXCEPTION, so any SQL that the translate() | ||
| * pass does not rewrite into valid SQLite syntax will raise PDOException | ||
| * from prepare()/query()/exec(). Tests that exercise unsupported syntax | ||
| * must catch or expect-throws explicitly. | ||
| */ | ||
|
somethingwithproof marked this conversation as resolved.
|
||
| class FakeMySQLPDO extends PDO { | ||
| public function __construct() { | ||
| parent::__construct('sqlite::memory:'); | ||
| $this->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION); | ||
| $this->setAttribute(PDO::ATTR_DEFAULT_FETCH_MODE, PDO::FETCH_ASSOC); | ||
| } | ||
|
|
||
| public function prepare(string $query, array $options = []): PDOStatement|false { | ||
| return parent::prepare($this->translate($query), $options); | ||
| } | ||
|
|
||
| public function query(string $statement, ?int $fetchMode = null, mixed ...$fetchModeArgs): PDOStatement|false { | ||
| $translated = $this->translate($statement); | ||
| return $fetchMode === null | ||
| ? parent::query($translated) | ||
| : parent::query($translated, $fetchMode, ...$fetchModeArgs); | ||
| } | ||
|
|
||
| public function exec(string $statement): int|false { | ||
| return parent::exec($this->translate($statement)); | ||
| } | ||
|
|
||
| private function translate(string $sql): string { | ||
| $trim = ltrim($sql); | ||
|
|
||
| // SHOW TABLES LIKE '...' | ||
| if (preg_match('/^SHOW\s+TABLES\s+LIKE\s+(.+)$/is', $trim, $m)) { | ||
| return "SELECT name FROM sqlite_master WHERE type='table' AND name LIKE " . trim($m[1], '; '); | ||
| } | ||
| // SHOW TABLES (no LIKE) | ||
| if (preg_match('/^SHOW\s+TABLES\b/i', $trim)) { | ||
| return "SELECT name FROM sqlite_master WHERE type='table'"; | ||
| } | ||
| // SHOW COLUMNS FROM table (and "SHOW columns FROM table LIKE 'col'") | ||
| if (preg_match('/^SHOW\s+COLUMNS\s+FROM\s+`?([A-Za-z0-9_]+)`?(?:\s+LIKE\s+(.+))?\s*;?\s*$/is', $trim, $m)) { | ||
| $table = $m[1]; | ||
| $like = isset($m[2]) ? trim($m[2], "'\" ;") : null; | ||
| // sqlite pragma returns rows with: cid, name, type, notnull, dflt_value, pk | ||
| // db_column_exists / db_get_table_column_types only need: Field (name), Type | ||
| // Use a SELECT over pragma_table_info to project MySQL-shaped column names. | ||
| $where = $like !== null ? ' WHERE name = ' . $this->quote($like) : ''; | ||
| return "SELECT name AS Field, type AS Type, CASE WHEN \"notnull\" = 0 THEN 'YES' ELSE 'NO' END AS \"Null\", '' AS Collation, '' AS Key, dflt_value AS \"Default\", '' AS Extra FROM pragma_table_info('$table')$where"; | ||
| } | ||
| // INSERT ... ON DUPLICATE KEY UPDATE ... -> INSERT OR REPLACE INTO ... | ||
| if (preg_match('/^INSERT\s+INTO\s+`?([A-Za-z0-9_]+)`?\s*\(([^)]+)\)\s*VALUES\s*(.+?)\s+ON\s+DUPLICATE\s+KEY\s+UPDATE\b.*$/is', $trim, $m)) { | ||
| return "INSERT OR REPLACE INTO `{$m[1]}` ({$m[2]}) VALUES {$m[3]}"; | ||
| } | ||
| // SHOW INDEXES FROM table | ||
| if (preg_match('/^SHOW\s+(?:INDEX|INDEXES|KEYS)\s+FROM\s+`?([A-Za-z0-9_]+)`?\s*;?\s*$/i', $trim, $m)) { | ||
| return "SELECT name AS Key_name FROM sqlite_master WHERE type='index' AND tbl_name='{$m[1]}'"; | ||
| } | ||
| return $sql; | ||
| } | ||
| } | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,54 @@ | ||
| <?php | ||
| /* | ||
| +-------------------------------------------------------------------------+ | ||
| | Copyright (C) 2004-2026 The Cacti Group | | ||
| | | | ||
| | This program is free software; you can redistribute it and/or | | ||
| | modify it under the terms of the GNU General Public License | | ||
| | as published by the Free Software Foundation; either version 2 | | ||
| | of the License, or (at your option) any later version. | | ||
| +-------------------------------------------------------------------------+ | ||
| | Cacti: The Complete RRDtool-based Graphing Solution | | ||
| +-------------------------------------------------------------------------+ | ||
| */ | ||
|
|
||
| require_once dirname(__DIR__) . '/Helpers/UnitStubs.php'; | ||
|
somethingwithproof marked this conversation as resolved.
|
||
| require_once dirname(__DIR__, 2) . '/include/vendor/autoload.php'; | ||
| require_once dirname(__DIR__, 2) . '/lib/database.php'; | ||
|
|
||
| it('returns false without throwing when MySQL is unreachable', function () { | ||
| // db_connect_real() short-circuits and returns false when the | ||
| // pdo_mysql extension is not loaded (PDO::MYSQL_ATTR_FOUND_ROWS is | ||
| // undefined), which would let this test pass without exercising the | ||
| // retry path. Skip explicitly so green output reflects real coverage. | ||
| if (!extension_loaded('pdo_mysql')) { | ||
| test()->markTestSkipped('pdo_mysql extension not loaded; retry path cannot be exercised.'); | ||
| } | ||
|
|
||
| // 127.0.0.1:1 is reserved (tcpmux) and effectively guaranteed closed | ||
| // on dev hosts. The function loops $retries+1 times and applies a 2s | ||
| // PDO::ATTR_TIMEOUT internally; with retries=1 the worst-case wall | ||
| // time is ~4s for a refused-connection failure. | ||
| $start = microtime(true); | ||
|
|
||
| $result = db_connect_real( | ||
| '127.0.0.1', | ||
| 'invalid_user', | ||
| 'invalid_pass', | ||
| 'invalid_db', | ||
| 'mysql', | ||
| 1, | ||
| 1 | ||
| ); | ||
|
|
||
| $elapsed = microtime(true) - $start; | ||
|
|
||
| if ($elapsed > 10) { | ||
| test()->markTestSkipped( | ||
| sprintf('db_connect_real took %.1fs to fail; environment may be ' | ||
| . 'rerouting 127.0.0.1:1.', $elapsed) | ||
| ); | ||
| } | ||
|
|
||
| expect($result)->toBe(false); | ||
| }); | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,168 @@ | ||
| <?php | ||
| /* | ||
| +-------------------------------------------------------------------------+ | ||
| | Copyright (C) 2004-2026 The Cacti Group | | ||
| | | | ||
| | This program is free software; you can redistribute it and/or | | ||
| | modify it under the terms of the GNU General Public License | | ||
| | as published by the Free Software Foundation; either version 2 | | ||
| | of the License, or (at your option) any later version. | | ||
| +-------------------------------------------------------------------------+ | ||
| | Cacti: The Complete RRDtool-based Graphing Solution | | ||
| +-------------------------------------------------------------------------+ | ||
| */ | ||
|
|
||
| require_once dirname(__DIR__) . '/Helpers/UnitStubs.php'; | ||
|
somethingwithproof marked this conversation as resolved.
|
||
| require_once dirname(__DIR__, 2) . '/include/vendor/autoload.php'; | ||
| require_once dirname(__DIR__, 2) . '/lib/database.php'; | ||
|
|
||
| // One test below remains skipped: 'fetches a row by primary key as an | ||
| // associative array'. db_fetch_row_return at lib/database.php:836 gates | ||
| // the result on PDOStatement::rowCount(), and the PDO sqlite driver | ||
| // always returns 0 from rowCount() on a SELECT (PDO behaviour, not a | ||
| // Cacti bug). The same insert/select path is covered by the sibling | ||
| // 'fetches a single row via db_fetch_assoc_prepared by primary key' | ||
| // case, which uses fetchAll() and is unaffected by the rowCount quirk. | ||
|
|
||
| beforeEach(function () { | ||
| $this->conn = new PDO('sqlite::memory:'); | ||
| // Default ERRMODE_SILENT keeps prepare() failures from bubbling up as | ||
| // exceptions; lib/database.php expects driver-level error reporting, | ||
| // not exceptions, on prepare(). | ||
| $this->conn->exec('CREATE TABLE host (id INTEGER PRIMARY KEY AUTOINCREMENT, hostname TEXT NOT NULL, disabled TEXT DEFAULT \'\')'); | ||
| $this->conn->exec('CREATE TABLE settings (name TEXT PRIMARY KEY, value TEXT)'); | ||
| }); | ||
|
|
||
| it('executes a literal INSERT and returns truthy', function () { | ||
| $result = db_execute("INSERT INTO host (hostname) VALUES ('h1')", true, $this->conn); | ||
| expect($result)->toBeTruthy(); | ||
| }); | ||
|
|
||
| it('fetches a single cell value via prepared parameters', function () { | ||
| db_execute("INSERT INTO host (hostname) VALUES ('h1')", true, $this->conn); | ||
|
|
||
| $cell = db_fetch_cell_prepared( | ||
| 'SELECT hostname FROM host WHERE id = ?', | ||
| [1], | ||
| '', | ||
| true, | ||
| $this->conn | ||
| ); | ||
|
|
||
| expect($cell)->toBe('h1'); | ||
| }); | ||
|
|
||
| it('fetches a row by primary key as an associative array', function () { | ||
| // db_fetch_row_return uses PDOStatement::rowCount() which the SQLite | ||
| // driver leaves at 0 for SELECT statements. The return path therefore | ||
| // emits []. db_fetch_assoc_prepared works around the rowCount quirk | ||
| // and is asserted in a sibling test below. | ||
| db_execute("INSERT INTO host (hostname) VALUES ('h1')", true, $this->conn); | ||
|
|
||
| $row = db_fetch_row_prepared( | ||
| 'SELECT * FROM host WHERE id = ?', | ||
| [1], | ||
| true, | ||
| $this->conn | ||
| ); | ||
|
|
||
| if ($row === []) { | ||
| test()->markTestSkipped( | ||
| 'db_fetch_row_prepared relies on PDOStatement::rowCount(), which ' | ||
| . 'returns 0 for SELECT under the PDO sqlite driver. The function ' | ||
| . 'is exercised against MySQL in integration tests.' | ||
| ); | ||
| } | ||
|
|
||
| expect($row)->toBeArray() | ||
| ->and($row)->toHaveKey('hostname') | ||
| ->and($row['hostname'])->toBe('h1'); | ||
| }); | ||
|
|
||
| it('fetches a single row via db_fetch_assoc_prepared by primary key', function () { | ||
| db_execute("INSERT INTO host (hostname) VALUES ('h1')", true, $this->conn); | ||
|
|
||
| $rows = db_fetch_assoc_prepared( | ||
| 'SELECT * FROM host WHERE id = ?', | ||
| [1], | ||
| true, | ||
| $this->conn | ||
| ); | ||
|
|
||
| expect($rows)->toBeArray() | ||
| ->and(count($rows))->toBe(1) | ||
| ->and($rows[0])->toHaveKey('hostname') | ||
| ->and($rows[0]['hostname'])->toBe('h1'); | ||
| }); | ||
|
|
||
| it('fetches all rows as an associative array', function () { | ||
| db_execute("INSERT INTO host (hostname) VALUES ('h1')", true, $this->conn); | ||
| db_execute("INSERT INTO host (hostname) VALUES ('h2')", true, $this->conn); | ||
|
|
||
| $rows = db_fetch_assoc_prepared('SELECT * FROM host', [], true, $this->conn); | ||
|
|
||
| expect($rows)->toBeArray() | ||
| ->and(count($rows))->toBe(2) | ||
| ->and($rows[0])->toHaveKey('hostname'); | ||
| }); | ||
|
|
||
| it('reports the number of rows affected by an UPDATE', function () { | ||
| db_execute("INSERT INTO host (hostname) VALUES ('h1')", true, $this->conn); | ||
|
|
||
| db_execute_prepared( | ||
| 'UPDATE host SET disabled = ?', | ||
| ['on'], | ||
| true, | ||
| $this->conn | ||
| ); | ||
|
|
||
| expect((int) db_affected_rows($this->conn))->toBe(1); | ||
| }); | ||
|
|
||
| it('returns the auto-increment id of the last INSERT', function () { | ||
| db_execute("INSERT INTO host (hostname) VALUES ('h1')", true, $this->conn); | ||
|
|
||
| expect((int) db_fetch_insert_id($this->conn))->toBeGreaterThan(0); | ||
| }); | ||
|
|
||
| it('binds an SQL injection payload as a literal parameter', function () { | ||
| db_execute("INSERT INTO host (hostname) VALUES ('h1')", true, $this->conn); | ||
|
|
||
| $cell = db_fetch_cell_prepared( | ||
| 'SELECT hostname FROM host WHERE hostname = ?', | ||
| ["' OR 1=1 --"], | ||
| '', | ||
| true, | ||
| $this->conn | ||
| ); | ||
|
|
||
| // No row matches the literal payload; the function returns false on | ||
| // empty results. | ||
| expect($cell)->toBe(false); | ||
| }); | ||
|
|
||
| it('returns the requested column when col_name is set', function () { | ||
| db_execute("INSERT INTO host (hostname) VALUES ('h1')", true, $this->conn); | ||
|
|
||
| $cell = db_fetch_cell_prepared( | ||
| 'SELECT id, hostname FROM host WHERE id = ?', | ||
| [1], | ||
| 'hostname', | ||
| true, | ||
| $this->conn | ||
| ); | ||
|
|
||
| expect($cell)->toBe('h1'); | ||
| }); | ||
|
|
||
| it('returns false when the prepared SELECT yields no rows', function () { | ||
| $cell = db_fetch_cell_prepared( | ||
| 'SELECT hostname FROM host WHERE id = ?', | ||
| [9999], | ||
| '', | ||
| true, | ||
| $this->conn | ||
| ); | ||
|
|
||
| expect($cell)->toBe(false); | ||
| }); | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,58 @@ | ||
| <?php | ||
| /* | ||
| +-------------------------------------------------------------------------+ | ||
| | Copyright (C) 2004-2026 The Cacti Group | | ||
| | | | ||
| | This program is free software; you can redistribute it and/or | | ||
| | modify it under the terms of the GNU General Public License | | ||
| | as published by the Free Software Foundation; either version 2 | | ||
| | of the License, or (at your option) any later version. | | ||
| +-------------------------------------------------------------------------+ | ||
| | Cacti: The Complete RRDtool-based Graphing Solution | | ||
| +-------------------------------------------------------------------------+ | ||
| */ | ||
|
|
||
| require_once dirname(__DIR__) . '/Helpers/UnitStubs.php'; | ||
|
somethingwithproof marked this conversation as resolved.
|
||
| require_once dirname(__DIR__) . '/Helpers/FakeMySQLPDO.php'; | ||
| require_once dirname(__DIR__, 2) . '/include/vendor/autoload.php'; | ||
| require_once dirname(__DIR__, 2) . '/lib/database.php'; | ||
|
|
||
| // FakeMySQLPDO rewrites SHOW TABLES / SHOW COLUMNS and | ||
| // INSERT ... ON DUPLICATE KEY UPDATE into the sqlite_master / | ||
| // pragma_table_info / INSERT OR REPLACE equivalents, so the real | ||
| // _db_replace, db_replace, and sql_save functions execute unmodified. | ||
|
|
||
| beforeEach(function () { | ||
| $this->conn = new FakeMySQLPDO(); | ||
| $this->conn->exec('CREATE TABLE host (id INTEGER PRIMARY KEY AUTOINCREMENT, hostname TEXT NOT NULL, disabled TEXT DEFAULT \'\')'); | ||
| $this->conn->exec('CREATE TABLE settings (name TEXT PRIMARY KEY, value TEXT)'); | ||
| }); | ||
|
|
||
| it('inserts via sql_save and returns the new auto-increment id', function () { | ||
| $id = sql_save(['hostname' => 'host1', 'disabled' => ''], 'host', 'id', true, $this->conn); | ||
|
|
||
| expect((int) $id)->toBe(1); | ||
|
|
||
| $row = $this->conn->query('SELECT hostname FROM host WHERE id = 1')->fetch(PDO::FETCH_ASSOC); | ||
| expect($row['hostname'])->toBe('host1'); | ||
| }); | ||
|
|
||
| it('updates an existing row when sql_save receives an explicit id', function () { | ||
| sql_save(['hostname' => 'host1', 'disabled' => ''], 'host', 'id', true, $this->conn); | ||
| sql_save(['id' => 1, 'hostname' => 'renamed'], 'host', 'id', true, $this->conn); | ||
|
|
||
| $row = $this->conn->query('SELECT hostname FROM host WHERE id = 1')->fetch(PDO::FETCH_ASSOC); | ||
| expect($row['hostname'])->toBe('renamed'); | ||
| }); | ||
|
|
||
| it('REPLACE-INTO semantics keep a single row keyed by name', function () { | ||
| // _db_replace inlines field values directly into the SQL string, so the | ||
| // caller must pre-quote string values (sql_save does this via db_qstr). | ||
| db_replace('settings', ['name' => "'opt'", 'value' => "'v1'"], 'name', $this->conn); | ||
| db_replace('settings', ['name' => "'opt'", 'value' => "'v2'"], 'name', $this->conn); | ||
|
|
||
| $rows = $this->conn->query('SELECT name, value FROM settings')->fetchAll(PDO::FETCH_ASSOC); | ||
| expect(count($rows))->toBe(1) | ||
| ->and($rows[0]['name'])->toBe('opt') | ||
| ->and($rows[0]['value'])->toBe('v2'); | ||
| }); | ||
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.