From 3f7050bf652bf3d736441af22a318ca23597c4db Mon Sep 17 00:00:00 2001 From: augushong Date: Sat, 18 Jul 2026 00:12:41 +0800 Subject: [PATCH] =?UTF-8?q?feat(test):=20=E6=A1=86=E6=9E=B6=E6=B5=8B?= =?UTF-8?q?=E8=AF=95=E5=9F=BA=E5=BB=BA=E5=9F=BA=E7=B1=BBTestCaseBase+app?= =?UTF-8?q?=E5=B1=82=E5=85=A5=E5=8F=A3+=E5=86=92=E7=83=9F=E6=B5=8B?= =?UTF-8?q?=E8=AF=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus --- app/common/test/TestCase.php | 16 ++ extend/base/common/test/TestCaseBase.php | 245 +++++++++++++++++++++++ tests/Feature/IsolationSmokeTest.php | 73 +++++++ 3 files changed, 334 insertions(+) create mode 100644 app/common/test/TestCase.php create mode 100644 extend/base/common/test/TestCaseBase.php create mode 100644 tests/Feature/IsolationSmokeTest.php diff --git a/app/common/test/TestCase.php b/app/common/test/TestCase.php new file mode 100644 index 0000000..45259b9 --- /dev/null +++ b/app/common/test/TestCase.php @@ -0,0 +1,16 @@ +assertTestDatabase(); + + // 开启事务:本测试所有 DB 写入都在此事务内,tearDown 统一回滚。 + // think-orm 3.0 事务 API:startTrans() 开启 / rollback() 回滚 / commit() 提交。 + Db::startTrans(); + } + + /** + * 每个测试结束后:回滚事务撤销所有 DB 写入(PRIMARY 隔离). + * + * 重要:这里【不会】调用 Container::setInstance(null)。 + * 原因:ThinkPHP 容器在 tests/bootstrap.php 中只引导一次(once-bootstrapped 模型), + * 跨测试复用同一个 App 实例。如果在这里把容器单例置 null,第二个测试起 + * 所有 facade(Db / Config 等)都会失去解析目标,整个测试套件崩溃。 + * 事务回滚才是隔离手段;容器是长生命的共享基础设施,不应被销毁。 + * 若确需清理框架级缓存单例,子类把 $resetContainerBetweenTests 置 true, + * 由 resetContainerState() 做最小化、安全的清理。 + * + * {@inheritdoc} + */ + protected function tearDown(): void + { + // 回滚事务:撤销本测试对数据库的一切写入 —— 真正的隔离在这里。 + // think-orm 3.0 事务 API:startTrans() 开启 / rollback() 回滚 / commit() 提交。 + Db::rollback(); + + if ($this->resetContainerBetweenTests) { + $this->resetContainerState(); + } + + parent::tearDown(); + } + + /** + * 最小化清理框架级缓存单例(可选). + * + * 默认实现为空:因为容器是跨测试复用的(见 tearDown 注释), + * 绝大多数场景不需要清理。只有当某测试确实污染了框架级单例、 + * 且事务回滚无法覆盖时,子类重写此方法做定向清理 + * (例如清某个缓存绑定,但【不要】整体销毁容器)。 + */ + protected function resetContainerState(): void + { + // 有意为空:扩展点,子类按需重写。 + } + + /** + * 解析当前默认数据库连接的 config 键前缀. + * + * 不硬编码连接名(本项目的默认连接是 `main`,但其它 ulthon 应用可能是 `mysql` 或别的)。 + * 动态读取 `database.default`(config/database.php: 'default' => Env::get('database.main', ...)), + * 拼成 `database.connections.`,供 assertTestDatabase / truncateTables 共用。 + */ + protected function connectionConfigKey(): string + { + $default = (string) Config::get('database.default', 'main'); + + return 'database.connections.' . $default; + } + + /** + * 安全校验:断言当前连接的数据库名包含 "test",拒绝连生产库. + * + * 通用判断(内核层不硬编码任何具体库名):库名含子串 "test" 视为测试库。 + * 例如 ulthon_admin_test(含 test)会被放行;远程生产库名(不含 test)会被拦截。 + * 这是 setUp 的第一道闸门,在任何 DB 写入之前执行。 + */ + protected function assertTestDatabase(): void + { + $name = (string) Config::get($this->connectionConfigKey() . '.database', ''); + + if ($name === '' || stripos($name, 'test') === false) { + $this->fail('Refusing to run tests against non-test database: ' . ($name !== '' ? $name : '(unknown)')); + } + } + + /** + * 断言指定表存在【至少一条】匹配 $where 的记录. + * + * @param string $table 逻辑表名(不含前缀,与 Db::name() 一致) + * @param array $where where 条件 + */ + protected function assertDatabaseHas(string $table, array $where): void + { + $count = Db::name($table)->where($where)->count(); + + if ($count <= 0) { + $this->fail(sprintf( + 'Failed asserting that table [%s] contains a row matching %s.', + $table, + json_encode($where, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES) + )); + } + } + + /** + * 断言指定表【不存在】任何匹配 $where 的记录. + * + * @param string $table 逻辑表名(不含前缀,与 Db::name() 一致) + * @param array $where where 条件 + */ + protected function assertDatabaseMissing(string $table, array $where): void + { + $count = Db::name($table)->where($where)->count(); + + if ($count > 0) { + $this->fail(sprintf( + 'Failed asserting that table [%s] does NOT contain any row matching %s (found %d).', + $table, + json_encode($where, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES), + $count + )); + } + } + + /** + * 隔离探针:自验证事务回滚隔离是否真正生效. + * + * 工作原理(跨测试自验证): + * 1. 先断言探针行【不存在】—— 若存在,说明上一个测试的事务没有被回滚(隔离失败); + * 2. 插入一条探针行; + * 3. 断言探针行【现在可见】—— 证明本测试事务内写入正常。 + * + * 完整契约:本方法插入的探针行必须被本测试 tearDown 的 Db::rollback() 撤销, + * 从而下一个测试再调 assertIsolationWorks() 时,第 1 步的"不存在"断言才能成立。 + * 如果事务回滚失效,第 1 步会在下一个测试立刻失败 —— 这就是探针的报警机制。 + * + * 内核层不硬编码业务表,探针表/数据由可重写的隔离钩子提供默认值(框架表 system_menu), + * app 层 app\common\test\TestCase 可重写 isolationProbeTable()/isolationProbeData() + * 改用真实业务表,使探针更贴近业务写入路径。 + * + * @api public —— 可直接作为测试用例方法或被测试用例显式调用 + */ + public function assertIsolationWorks(): void + { + $table = $this->isolationProbeTable(); + $data = $this->isolationProbeData(); + + // 1. 前置:探针行必须不存在(证明上一测试已回滚干净) + $this->assertDatabaseMissing($table, $data); + + // 2. 插入探针行 + Db::name($table)->insert($data); + + // 3. 后置:探针行必须可见(证明本测试事务内写入正常) + $this->assertDatabaseHas($table, $data); + } + + /** + * 隔离探针表(可重写). + * + * 默认返回框架表 system_menu(migrate:run 必建,所有应用都有)。 + * app 层可重写为真实业务表,使探针更贴近业务写入路径。 + * 注意:内核层不允许返回业务表名,那属于 app 层重写。 + */ + protected function isolationProbeTable(): string + { + return 'system_menu'; + } + + /** + * 隔离探针插入数据(可重写). + * + * 默认向 system_menu 插入一条带高辨识度标记的行(system_menu 的 title 列有默认值, + * 仅写 title 即可,其余列走默认)。返回的数组同时作为可见性断言的 where 条件。 + */ + protected function isolationProbeData(): array + { + return ['title' => '__isolation_probe__']; + } + + /** + * Plan B:TRUNCATE 指定表(事务回滚隔离的降级方案). + * + * 用途:如果事务回滚在 think-orm 3.0 下证明不足以隔离(例如某些 DDL / 自动提交场景), + * 子类可在 setUp 中显式调用本方法清空指定表。事务回滚仍是首选方案,本方法仅作兜底。 + * + * @param array $tables 逻辑表名数组(不含前缀,与其它方法一致;内部会拼接配置前缀) + */ + protected function truncateTables(array $tables): void + { + $prefix = (string) Config::get($this->connectionConfigKey() . '.prefix', ''); + + foreach ($tables as $table) { + $table = (string) $table; + + // 标识符白名单校验,杜绝 SQL 注入(TRUNCATE 是高危操作) + if (!preg_match('/^[A-Za-z0-9_]+$/', $table)) { + throw new \InvalidArgumentException( + 'Refusing to TRUNCATE: invalid table identifier (only [A-Za-z0-9_] allowed): ' . $table + ); + } + + Db::execute('TRUNCATE TABLE `' . $prefix . $table . '`'); + } + } +} diff --git a/tests/Feature/IsolationSmokeTest.php b/tests/Feature/IsolationSmokeTest.php new file mode 100644 index 0000000..2fe8fb0 --- /dev/null +++ b/tests/Feature/IsolationSmokeTest.php @@ -0,0 +1,73 @@ +assertIsolationWorks(); + + // 显式成功标记(assertIsolationWorks 不带返回值,避免 risky test 告警)。 + $this->assertTrue(true, 'isolation probe inserted and is visible within the transaction'); + } + + /** + * 拒绝非测试库:临时把连接库名改为不含 "test" 的值, + * 反射调用 protected assertTestDatabase 应触发 fail()。 + * + * 使用 try/catch/finally 而非 expectException,目的是无论是否抛出 + * 都能在 finally 中恢复 config,避免污染后续测试。 + */ + public function test_refuses_non_test_database(): void + { + // think 的 Config::set 不支持点号多级路径(见 bootstrap 注释), + // 必须像 bootstrap 那样整段 pull 出来改了再 set 回去。 + $dbConfig = Config::get('database'); + $backup = is_array($dbConfig) ? $dbConfig : []; + + $thrown = null; + try { + $dbConfig['connections']['main']['database'] = 'ulthon_admin_production_safe_marker'; + Config::set($dbConfig, 'database'); + + // 反射调用 protected assertTestDatabase(setUp 里已自动调过一次并通过, + // 这里在事务内二次校验非测试库名,应当 fail)。 + $method = new ReflectionMethod(self::class, 'assertTestDatabase'); + $method->setAccessible(true); + $method->invoke($this); + } catch (AssertionFailedError $e) { + $thrown = $e; + } finally { + // 无论是否抛出,恢复 config —— 不污染同一进程后续测试。 + Config::set($backup, 'database'); + } + + $this->assertNotNull($thrown, 'assertTestDatabase should fail when database name lacks "test"'); + $this->assertStringContainsString('non-test database', $thrown->getMessage()); + } +}