## Problem
Production was returning:
```json
{"success":false,"data":{"message":"Internal server error: Unknown column 'invoice_email' in 'SELECT'"}}
```
when authenticating as a superuser. The migration that adds
`users.invoice_email` was merged to master in api#381 but never applied
to the production database.
## Fix
- New `GET /api/admin/schema-check` endpoint — returns 503 with explicit
list of missing columns if any are absent (instead of a generic 500)
- `scripts/run-schema-bootstraps.php` — auto-discovers and runs every
`*_schema_bootstrap` class on the live database (additive, idempotent)
- `scripts/schema-health-check.php` — CLI tool for the same check, used
by deploy pipelines
- New Pest contract test `SchemaHealthCheckTest` — verifies the test DB
has every required users column and the schema-check endpoint works
- `deploy.yml`: pre-deploy step runs the bootstrap runner, smoke test
also runs the schema check, Slack alert on failure
## What this prevents
- Future migrations being merged without being applied to production
- Silent failures (generic 500) when a column is missing
- Repeated manual investigation of the same root cause
Refs: TRU-77 (the original bug), api#381 (the original PR)
---------
Co-authored-by: Jeppe B <jeppe@copenhagentruckwash.io>
Co-authored-by: bugfix-subagent <bugfix-subagent@truckwash.local>
Co-authored-by: OpenClaw Bugfix Agent <bugfix@openclaw.local>
221 lines
8.0 KiB
PHP
221 lines
8.0 KiB
PHP
<?php
|
|
|
|
/**
|
|
* Contract test: every column that the code expects to find in the
|
|
* `users` table must exist. Catches the production failure mode
|
|
* where a migration was added to code but never run on the database
|
|
* (e.g. "Unknown column 'invoice_email' in 'SELECT'" — TRU-77).
|
|
*
|
|
* This test runs against the test database (configured in
|
|
* phpunit.xml / Pest configuration). It does NOT run against
|
|
* production — that's covered by the `/admin/schema-check` HTTP
|
|
* endpoint in `adminRoute.php` which the deploy pipeline hits.
|
|
*/
|
|
|
|
app_require('classes/customer_invoice_email_schema_bootstrap.php');
|
|
|
|
use classes\customer_invoice_email_schema_bootstrap;
|
|
|
|
const REQUIRED_USERS_COLUMNS = [
|
|
// TRU-77 (added 2026-08-16) — the column that was missing in
|
|
// production after the migration was merged to master.
|
|
'invoice_email',
|
|
// Older required columns that the code references.
|
|
'wash_certificate_email',
|
|
'email',
|
|
'customer_number',
|
|
'phone_country_code',
|
|
'phone',
|
|
'group_id',
|
|
'created_at',
|
|
];
|
|
|
|
/**
|
|
* The unit test bootstrap does not create a $db global. This contract
|
|
* test is unique in that it needs a real database to verify schema
|
|
* state, so wire one up here using the same CONFIG_DB_* env vars the
|
|
* rest of the CI suite exports. If the database is unavailable, the
|
|
* tests below will fail with a clear "no_db_connection" error.
|
|
*/
|
|
schema_health_check_test_wire_db();
|
|
|
|
function schema_health_check_test_wire_db(): void
|
|
{
|
|
if (isset($GLOBALS['db']) && is_object($GLOBALS['db'])) {
|
|
return;
|
|
}
|
|
if (!class_exists('mysqli')) {
|
|
return;
|
|
}
|
|
|
|
$host = (string)(getenv('CONFIG_DB_HOST') ?: 'mysql-debug');
|
|
$user = (string)(getenv('CONFIG_DB_USER') ?: 'root');
|
|
$password = (string)(getenv('CONFIG_DB_PASSWORD') ?: 'debug_root_password');
|
|
$database = (string)(getenv('CONFIG_DB_DATABASE') ?: 'nnks_db_debug');
|
|
$port = (int)(getenv('CONFIG_DB_PORT') ?: 3306);
|
|
|
|
try {
|
|
mysqli_report(MYSQLI_REPORT_OFF);
|
|
$conn = new mysqli($host, $user, $password, $database, $port);
|
|
if ($conn->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);
|
|
});
|