Files
ulthon_admin/tests/setup_test_db.php
augushong 37cb8291f8 feat(test): 引入测试规则文档与参数化测试库初始化脚本
对齐 wkbox 衍生项目的测试规则体系,剥离业务特定内容后移植到框架:

- 规则 .agents/rules/ulthon-testing.md(设计哲学/决策树/4层模型/安全规范/fixture原则)
- 技能 .agents/skills/ulthon-testing(运行/编写/模板/决策/回归验证)
- 参数化建库脚本 tests/setup_test_db.{php,ps1,sh} + README
- AGENTS.md 索引补充

与 wkbox 差异:测试库参数化(复用 .env 连接,database=env+'_test'),不硬编码 Docker;删去 canBind Oracle 业务约束与具体 fixture 工厂,业务工厂由衍生项目自行实现。
2026-07-18 23:56:32 +08:00

213 lines
12 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<?php
/**
* 测试数据库初始化脚本(参数化版).
*
* 在与 .env 同实例的 MySQL 上建出 ulthon_admin_test 库,并初始化全部表:
* 1. migrate:run 建系统/框架表database/migrations/,如 ul_system_admin / ul_system_menu
* 2. scheme:sync 建业务表app/admin/scheme/
* 3. seed:run 插入最小参考数据database/seeds/InitBaseAdminData
*
* 与 wkbox 衍生项目的区别:
* - 完全参数化连接参数hostname/hostport/username/password/charset/prefix/database
* 全部从 .env 读取,复用开发库同实例的 MySQL不依赖专用 Docker 容器,不硬编码端口。
* - 测试库名 = .env 中 DATABASE + '_test' 后缀(如 ulthon_admin → ulthon_admin_test
* - 脚本本身负责 CREATE DATABASE IF NOT EXISTS单机 MySQL库可能尚未创建
*
* 连接覆盖逻辑与 tests/bootstrap.php 完全一致(必须保持同步,否则脚本与 PHPUnit 跑的
* 不是同一个测试库)。关键技术点见 tests/README.md「技术细节」一节
* - ThinkPHP 的 Env 加载 .env 且 .env 值优先于 OS 环境变量putenv/$env:VAR/bash 前缀
* 都无法覆盖已存在的 .env 键,唯一可靠覆盖点是 config 级。
* - think\Config::set 的第二参数只支持单段一级名,不能传 'database.connections.main'。
*
* 幂等migrate:run / scheme:sync / seed:run 均可安全重复执行(详见 README
*/
declare(strict_types=1);
// ----------------------------------------------------------------------------
// 1. 启动 ThinkPHP 容器(必须先 require autoload再 initialize
// ----------------------------------------------------------------------------
require __DIR__ . '/../vendor/autoload.php';
$app = new \think\App();
$app->initialize();
// ----------------------------------------------------------------------------
// 2. 从 .env 读取连接参数,组装测试库连接配置
// 与 tests/bootstrap.php 完全一致:复用开发库同实例连接,仅替换 database 名。
// ----------------------------------------------------------------------------
$envHostname = \think\facade\Env::get('database.hostname');
$envHostport = \think\facade\Env::get('database.hostport');
$envUsername = \think\facade\Env::get('database.username');
$envPassword = \think\facade\Env::get('database.password');
$envCharset = \think\facade\Env::get('database.charset');
$envPrefix = \think\facade\Env::get('database.prefix');
$envDatabase = \think\facade\Env::get('database.database');
// 测试库名策略:原库名 + _test 后缀(如 ulthon_admin → ulthon_admin_test
$testDatabase = is_string($envDatabase) && $envDatabase !== ''
? $envDatabase . '_test'
: 'ulthon_admin_test';
$hostname = is_string($envHostname) && $envHostname !== '' ? $envHostname : '127.0.0.1';
$hostport = is_string($envHostport) && $envHostport !== '' ? $envHostport : '3306';
$username = is_string($envUsername) && $envUsername !== '' ? $envUsername : 'root';
$password = is_string($envPassword) ? $envPassword : '';
$charset = is_string($envCharset) && $envCharset !== '' ? $envCharset : 'utf8mb4';
$prefix = is_string($envPrefix) && $envPrefix !== '' ? $envPrefix : 'ul_';
// ----------------------------------------------------------------------------
// 3. 早期安全护栏:解析出的库名必须含 'test',否则拒绝继续
// (在任何写操作之前触发,避免误连开发库)
// ----------------------------------------------------------------------------
if (stripos($testDatabase, 'test') === false) {
throw new \RuntimeException("拒绝执行:解析到的测试库名 [{$testDatabase}] 不含 'test',已中止。");
}
fwrite(STDOUT, "==== 测试库目标:{$testDatabase} @ {$hostname}:{$hostport}user: {$username} ====\n");
// ----------------------------------------------------------------------------
// 4. 确保测试库存在(用 PDO 直连,不指定 database执行 CREATE DATABASE IF NOT EXISTS
// 单机 MySQL 场景下测试库可能尚未创建Docker 预 provision 的库也无妨IF NOT EXISTS 幂等。
// ----------------------------------------------------------------------------
try {
$dsn = "mysql:host={$hostname};port={$hostport};charset={$charset}";
$pdo = new \PDO($dsn, $username, $password, [
\PDO::ATTR_ERRMODE => \PDO::ERRMODE_EXCEPTION,
]);
$quotedDb = '`' . str_replace('`', '``', $testDatabase) . '`';
$pdo->exec("CREATE DATABASE IF NOT EXISTS {$quotedDb} CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci");
fwrite(STDOUT, "==== 确保 TEST_DB 已存在:{$testDatabase}utf8mb4 / utf8mb4_unicode_ci ====\n");
} catch (\Throwable $e) {
fwrite(STDERR, "[ERROR] 创建测试库失败:{$e->getMessage()}\n");
fwrite(STDERR, " 请检查 .env 中 HOSTNAME/HOSTPORT/USERNAME/PASSWORD 是否能连上 MySQL。\n");
exit(1);
}
// ----------------------------------------------------------------------------
// 5. config 级覆盖 connections.main 指向测试库
// 覆盖目标 = connections.main本项目 config/database.php 的默认连接名是 main
// 坑点think\Config::set(array, $name) 的 $name 只支持单段一级配置名,
// 不像 Config::get 那样解析点号多级路径。正确做法:把整段 'database' 配置 pull 出来,
// 原地改 connections.main再 Config::set($dbConfig, 'database') 写回。
// 必须保留 ['query' => \app\common\provider\db\Query::class],否则 ORM 行为异常。
// ----------------------------------------------------------------------------
$dbConfig = \think\facade\Config::get('database');
if (!is_array($dbConfig)) {
$dbConfig = [];
}
$dbConfig['default'] = 'main';
if (!isset($dbConfig['connections']) || !is_array($dbConfig['connections'])) {
$dbConfig['connections'] = [];
}
if (!isset($dbConfig['connections']['main']) || !is_array($dbConfig['connections']['main'])) {
$dbConfig['connections']['main'] = [];
}
$dbConfig['connections']['main'] = array_merge(
$dbConfig['connections']['main'],
[
'type' => 'mysql',
'hostname' => $hostname,
'hostport' => $hostport,
'database' => $testDatabase,
'username' => $username,
'password' => $password,
'charset' => $charset,
'prefix' => $prefix,
'fields_cache' => false,
],
// 必须保留框架自定义 query 类,否则 ORM 行为异常。
['query' => \app\common\provider\db\Query::class],
);
\think\facade\Config::set($dbConfig, 'database');
// 二次护栏:写回 config 之后再校验一次,确认覆盖真的生效。
$resolvedDb = \think\facade\Config::get('database.connections.main.database');
if (!is_string($resolvedDb) || stripos($resolvedDb, 'test') === false) {
throw new \RuntimeException('Config 覆盖未生效resolved database = ' . var_export($resolvedDb, true));
}
// ----------------------------------------------------------------------------
// 6. 逐条执行建表/建库命令(同进程 Console::callconfig 覆盖对命令同样生效)
// Console::call 返回 think\console\Output命令输出被写进其私有 handleBuffer
// 用反射取出 Buffer 内容回显,便于排查;同时去掉 ANSI 着色码避免终端转义污染日志。
// --force-force 是框架全局选项,命中时 confirm() 直接返回默认值,跳过所有交互确认——
// scheme:sync 在非 TTY 下不带它会被 parent::confirm 抛 "Aborted"。
// ----------------------------------------------------------------------------
$commands = [
// 系统/框架表Phinx 迁移ul_system_admin / ul_system_menu / ul_system_auth_node / ul_system_config / ...
['migrate:run', '系统/框架表database/migrations/'],
// 业务表Scheme 同步app/admin/scheme/ 下声明的表
['scheme:sync', '业务表app/admin/scheme/'],
// 参考数据seedInitBaseAdminData自带 install-lock 跳过,可安全重跑
['seed:run', '参考数据database/seeds/'],
];
$handleProp = (new \ReflectionProperty(\think\console\Output::class, 'handle'));
$handleProp->setAccessible(true);
foreach ($commands as [$cmd, $desc]) {
fwrite(STDOUT, "\n>>>> [{$cmd}] {$desc}\n");
try {
/** @var \think\console\Output $output */
$output = \think\facade\Console::call($cmd, ['--force-force']);
// 取出 Buffer 里命令自身打印的内容并回显
$handle = $handleProp->getValue($output);
$captured = ($handle instanceof \think\console\output\driver\Buffer) ? $handle->fetch() : '';
if ($captured !== '') {
// 去掉 ANSI 着色码,避免终端转义污染日志
$captured = preg_replace('/\x1b\[[0-9;]*m/', '', $captured);
fwrite(STDOUT, rtrim($captured) . "\n");
}
fwrite(STDOUT, "<<<< [{$cmd}] 完成(无异常)\n");
} catch (\Throwable $e) {
// 单条命令失败不立刻退出,继续后续命令,最后由验证段汇总。
fwrite(STDERR, "<<<< [{$cmd}] 异常:{$e->getMessage()}\n");
}
}
// ----------------------------------------------------------------------------
// 7. 验证:直接查 information_schema.tables WHERE table_schema=?,证明表确实落在测试库
// ulthon 是框架仓库,没有 app_* 业务表,只断言系统表 ul_system_* 关键四张存在。
// ----------------------------------------------------------------------------
fwrite(STDOUT, "\n==== 验证:{$resolvedDb} 内表清单 ====\n");
try {
// 用裸 SQL 查 information_schema绕开 ORM 的前缀/字段缓存 quirks
$rows = \think\facade\Db::connect('main')->query(
'SELECT table_name AS t FROM information_schema.tables WHERE table_schema = ? ORDER BY table_name',
[$resolvedDb],
);
$names = array_column($rows, 't');
$totalCount = count($names);
fwrite(STDOUT, "表总数:{$totalCount}\n");
// 分类展示:系统表 vs 业务表 vs 其它(按命名约定 ul_system_* / ul_app_* / 其它)
$systemTables = array_values(array_filter($names, static fn ($n) => stripos($n, 'system') !== false));
$appTables = array_values(array_filter($names, static fn ($n) => stripos($n, 'app_') !== false));
$otherTables = array_values(array_diff($names, $systemTables, $appTables));
fwrite(STDOUT, "系统表system来自 migrate:run" . (empty($systemTables) ? '(无)' : implode(', ', $systemTables)) . "\n");
fwrite(STDOUT, "业务表app_来自 scheme:sync" . (empty($appTables) ? '(无)' : implode(', ', $appTables)) . "\n");
if (!empty($otherTables)) {
fwrite(STDOUT, "其它表migrations / debug_log / backup 等):" . implode(', ', $otherTables) . "\n");
}
// 关键系统表必存在断言ulthon 框架核心:登录、菜单、权限节点、配置)
$required = ['ul_system_admin', 'ul_system_menu', 'ul_system_auth_node', 'ul_system_config'];
$missing = array_values(array_filter($required, static fn ($n) => !in_array($n, $names, true)));
if (!empty($missing)) {
fwrite(STDERR, "\n[警告] 缺少关键系统表:" . implode(', ', $missing) . "\n");
fwrite(STDERR, " 检查 migrate:run 是否真的连到测试库并成功执行。\n");
exit(2);
}
fwrite(STDOUT, "\n[OK] 关键系统表全部存在system_admin / system_menu / system_auth_node / system_config\n");
} catch (\Throwable $e) {
fwrite(STDERR, "验证查询失败:{$e->getMessage()}\n");
exit(3);
}
fwrite(STDOUT, "\n==== 测试数据库初始化完成 ====\n");
exit(0);