connect_errno) { return; } $conn->set_charset('utf8mb4'); } catch (\Throwable $e) { return; } $GLOBALS['db'] = new class($conn) { private mysqli $conn; public function __construct(mysqli $conn) { $this->conn = $conn; } public function query(string $sql) { return $this->conn->query($sql); } public function fetch_assoc($result) { return $result ? $result->fetch_assoc() : null; } public function close(): void { try { $this->conn->close(); } catch (\Throwable) { } } }; } /** * The unit suite starts with a freshly-dropped `nnks_db_debug` database * (see run_ci_suite::reset_ci_state). The schema bootstrap only owns * the `users` table; `invoices` and `bookings` are managed by other * migrations that don't run in the unit suite. Create the bare-minimum * schema that adminRoute::runSchemaCheck needs so the third test can * verify the "all columns exist" happy path. */ function schema_health_check_test_ensure_aux_tables(): void { global $db; if (!isset($db) || !is_object($db) || !method_exists($db, 'query')) { return; } $create = function (string $table, string $createSql, array $requiredColumns) use ($db): void { $r = $db->query("SHOW TABLES LIKE '{$table}'"); if (!$r || (int)$r->num_rows === 0) { $db->query($createSql); return; } foreach ($requiredColumns as $column => $definition) { $r = $db->query("SHOW COLUMNS FROM `{$table}` LIKE '{$column}'"); if (!$r || (int)$r->num_rows === 0) { $db->query("ALTER TABLE `{$table}` ADD COLUMN `{$column}` {$definition}"); } } }; $create('invoices', "CREATE TABLE `invoices` ( id INT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY, customer_number INT NOT NULL DEFAULT 0, po_number VARCHAR(64) NULL, closed_at DATETIME NULL ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci", [ 'po_number' => 'VARCHAR(64) NULL', 'closed_at' => 'DATETIME NULL', 'customer_number' => 'INT NOT NULL DEFAULT 0', ]); $create('bookings', "CREATE TABLE `bookings` ( id INT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY, customer_number INT NOT NULL DEFAULT 0, department INT NULL ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci", [ 'customer_number' => 'INT NOT NULL DEFAULT 0', 'department' => 'INT NULL', ]); } beforeEach(function () { // Force a fresh real DB connection. Earlier unit tests in the // same process may have left $GLOBALS['db'] as a Mockery mock, // which would cause the schema bootstrap below to silently no-op // and leave the `users` table uncreated. The wiring helper // short-circuits when a $db is already set, so we unset first. unset($GLOBALS['db']); schema_health_check_test_wire_db(); if (class_exists(customer_invoice_email_schema_bootstrap::class)) { // Reset the bootstrap's static `$initialized` cache. A // previous test (possibly against a mock $db) may have set // it to true, which would cause ensureSchema() to skip // creating the `users` table on our real connection. $bootstrapRef = new ReflectionClass(customer_invoice_email_schema_bootstrap::class); $initProp = $bootstrapRef->getProperty('initialized'); $initProp->setAccessible(true); $initProp->setValue(null, false); // Self-heal: run the schema bootstrap so the test DB has all // the columns the contract requires. The bootstrap is additive // and idempotent — safe to run on every test. customer_invoice_email_schema_bootstrap::ensureSchema(); } schema_health_check_test_ensure_aux_tables(); }); it('users table has every required column the code references', function () { global $db; expect($db)->toBeObject(); expect(method_exists($db, 'query'))->toBeTrue(); $missing = []; foreach (REQUIRED_USERS_COLUMNS as $column) { $safeColumn = str_replace("'", '', $column); $result = $db->query("SHOW COLUMNS FROM `users` LIKE '{$safeColumn}'"); if (!$result || (int)$result->num_rows === 0) { $missing[] = $column; } } expect($missing)->toBe( [], "users table is missing required columns: " . implode(', ', $missing) . ". Did the migration run? See customer_invoice_email_schema_bootstrap." ); }); it('invoice_email column accepts a normal email address', function () { global $db; // Insert a throwaway user with an invoice_email, read it back. // If the column doesn't exist or the type is wrong, this fails. $email = 'test-invoice-' . uniqid() . '@example.com'; $customerNumber = 99900000 + random_int(1, 99999); $db->query("INSERT INTO `users` (customer_number, display_name, email, invoice_email) VALUES ({$customerNumber}, 'Contract Test', '{$email}', 'invoice@example.com')"); $result = $db->query("SELECT invoice_email FROM `users` WHERE customer_number = {$customerNumber}"); expect($result)->toBeObject(); $row = $result->fetch_assoc(); expect($row['invoice_email'] ?? null)->toBe('invoice@example.com'); // Cleanup $db->query("DELETE FROM `users` WHERE customer_number = {$customerNumber}"); }); it('admin schema-check endpoint reports ok=true when all columns exist', function () { $admin = new \routes\adminRoute(); $reflection = new ReflectionClass($admin); $method = $reflection->getMethod('runSchemaCheck'); $method->setAccessible(true); $report = $method->invoke($admin); expect($report['ok'])->toBeTrue( 'schema check failed: ' . json_encode($report['missing'] ?? []) ); expect($report['columns_checked'])->toBeGreaterThan(0); });