diff --git a/.agents/rules/ulthon-testing.md b/.agents/rules/ulthon-testing.md new file mode 100644 index 0000000..9ae2728 --- /dev/null +++ b/.agents/rules/ulthon-testing.md @@ -0,0 +1,177 @@ +# 测试设计哲学与边界规范 + +> 来源:框架内置(ulthon-) +> 作用域:所有测试编写、service 抽象、控制器逻辑组织 +> 触发条件:判断"该不该抽 service / 该不该写测试"、写 PHPUnit、动测试库时加载 + +本规则回答两个问题:(1) 框架里那么多业务逻辑没写单元测试,是不是不规范?(2) 哪些逻辑必须抽 service 并锁测试?回答之前先理解框架的设计取向,否则会把"设计如此"误判成"技术债"去重构,反而制造破坏。 + +## 一、核心设计哲学 + +防止后人或 AI 把框架的刻意设计误判为缺陷,这里把四条取向讲透。 + +### 1.1 控制器中心主义 + +业务逻辑写在控制器里,是设计如此,不是不规范。 + +框架侧重控制器。控制器等于接口等于页面,业务入口、参数校验、结果反馈都在这里完成。一个控制器方法同时承担"渲染页面"和"返回 JSON"两种职责(见 1.2),所以把业务流程写在控制器里,流程跟入口是同一段代码,改起来最直接,调试链路最短。 + +不要因为"控制器太胖"就机械地拆 service。控制器胖是因为业务就发生在这里,把业务搬到 service 只是把同样的代码换个文件,还多了一层跳转成本。 + +### 1.2 页面接口同体 + +一套控制器代码同时服务三端:后台管理页、用户端接口、小程序接口。消除"接口分叉"是这套机制的核心价值。 + +严禁拆成 `ApiController` + `PageController` 两套。一旦拆开,三端逻辑就会各自漂移,今天改了页面端忘了同步接口端,明天接口端加了字段页面端没跟上,最终三端行为不一致。同体机制的价值有五条: + +1. **一套逻辑不会分叉**:同一方法同一代码路径,三端拿到的结果数学上完全一致。 +2. **接口测试 ROI 放大**:测一条路径等于同时覆盖页面和接口,不用维护两套测试。 +3. **改一处生效三端**:业务变更只改一个方法,三端同步生效,没有"忘了同步"的窗口。 +4. **框架机制保证三模式自动切换**:同体不是手写 if-else 判断请求类型,框架按请求特征自动选择渲染或 JSON 响应,开发者无感。 +5. **这不是图省事,是消除分叉风险**:同体是架构决策,不是偷懒。拆开的代价(三端不一致的线上事故)远大于合并的代价(一个方法稍长)。 + +详细机制见技能 [ulthon-page-api-dual-mode](../skills/ulthon-page-api-dual-mode/SKILL.md)。 + +### 1.3 按需 service + +框架对 service 的规范是:**多应用复用才放 `app/common/service/`**。 + +大多数应用业务不需要封装 service。把"只用一次的业务流程"硬抽成 service,只是把代码搬离控制器,没有复用收益,反而增加了跳转和传参成本。 + +只有两种情况才值得抽 service: + +- **(a) 产品工具型逻辑**:算法、编号解析、格式转换这类"输入输出确定、不变、多处用"的逻辑。例如编号生成器、二维码解析器。它们是工具,不是业务流程。 +- **(b) 不可逆且致命的约束**:错了会出真实事故的校验逻辑。例如余额扣减的"不能扣成负数"、绑定约束的"不能重复绑定"、状态流转的"不可逆转换"。这种逻辑必须从控制器流程里独立出来,单独测、单独审、单独防回归。 + +### 1.4 做应用业务 ≠ 开发产品工具 + +这两类代码的价值取向完全相反,不能用同一套规范套。 + +- **应用业务**追求快速响应变化。业务规则经常变(今天满减门槛 100 元,明天改 80 元),逻辑写死在控制器里反而最好改。过度封装 service 和写单元测试,会让"改一个数字"变成"改 service + 改测试 + 改 mock 数据"三件事,成为响应变化的障碍。 +- **产品工具**追求绝对正确性。sqlite 管理、网盘协议、解析器这类工具,输入输出契约一旦确定就不该变,错了就是工具本身坏了。这里 service 和单元测试很有价值,因为不变量是死的,测试能长期守护。 + +判断一段逻辑属于哪类:问"它会变吗,还是它该永远对?"。会变的是应用业务,该永远对的是产品工具。 + +## 二、判断决策树:该不该抽 service / 该不该写测试 + +判断标准有且只有两个维度:**错了的后果** + **是否多处复用**。注意,判断标准不是"会不会变"。业务会变不代表不该封装,余额规则再怎么变,"不能扣成负数""不能重复扣"这些不变量是死的,值得锁住。 + +``` +这段逻辑错了会怎样? +├─ 后果不可承受(钱/医疗/不可逆/法律)→ 必须抽 service + 写测试 +│ 余额扣减、状态流转、绑定约束、支付回调、库存扣减... +├─ 后果可承受,但逻辑被多处复用 → 抽 service(DRY),测试看情况 +│ 消息发送、文件上传、数据导出、编号生成... +└─ 后果可承受,且只用一次 → 写控制器里,http:call 验证就够 + 字段名、展示顺序、列表筛选、表单校验、页面跳转... +``` + +三条分支的落地: + +- **后果不可承受**:抽到 `app/common/service/`,配 PHPUnit 集成测试(见第三节"核心 service"层)。测试要锁死不变量,不只是覆盖当前行为。 +- **后果可承受 + 多处复用**:抽 service 是为了 DRY,不是为了测试。测试看 ROI,纯函数值得测,带状态的可放可不放。 +- **只用一次**:留在控制器里。用 `php think tools:http:call`(见技能 ulthon-tools-http-call)模拟请求跑通增删改查即可,这是应用业务的主力验证方式,不需要 PHPUnit。 + +## 三、4 层测试模型 + +大部分应用业务逻辑【不测】。只在下表对应层介入。 + +| 层 | 测什么 | 工具 | 适用场景 | +|------|--------|------|----------| +| 纯函数 | 编号解析、算法、格式转换 | 裸 `assert` / 极简 PHPUnit | 产品工具型逻辑 | +| 控制器 | 列表、表单、筛选、跳转、增删改查 | `php think tools:http:call` | 应用业务主力 | +| 核心 service | 致命约束、状态流转、不可逆操作 | PHPUnit 集成测试(TestCaseBase) | 不可逆且致命 | +| 并发专项 | 抢购、库存扣减、临界区 | 独立并发脚本 | 高并发临界区 | + +说明: + +- **控制器层**用 `tools:http:call` 而非 PHPUnit,因为它能跑通"鉴权 + 路由 + 中间件 + 控制器 + 模型 + 视图"整条链,PHPUnit 单测控制器往往需要 mock 一堆依赖,ROI 很低。这与 AGENTS.md"调试与验证优先使用框架内置命令行工具"一致。 +- **核心 service 层**才用 PHPUnit 集成测试,且必须继承 `app\common\test\TestCase`(见第五、六节),跑在真实测试库 + 事务回滚隔离里。 +- **纯函数层**可以用裸 `assert`(一个 `php` 脚本跑完),不一定要进 PHPUnit 套件,除非逻辑足够复杂值得长期守护。 + +## 四、service 层测试边界 + +框架规范"多应用复用才放 `app/common/service/`"(第一节 1.3)和"可测性需求"之间有张力:有些逻辑只用一次,但错了后果不可承受。 + +何时破例:**致命约束型 service 值得破例抽出来测**。即使一段逻辑只在当前应用的某个流程里用一次,只要它错了是资金事故/法律事故/安全事故(决策树第一分支),就值得抽成 service 配测试。理由是测试需要稳定的入口和可复现的 fixture,控制器方法很难提供这两个条件(请求上下文、事务、鉴权状态都会干扰),service 的纯函数式签名天生适合测试。 + +反过来说,后果可承受的逻辑(决策树第二、三分支)不要为了"可测"硬抽 service。留在控制器里用 `tools:http:call` 验证,更符合应用业务的响应变化需求。 + +典型破例场景:余额扣减的"不能扣成负数"、唯一性绑定约束、不可逆状态流转、支付回调验签、库存扣减的"不能超卖"。这些逻辑错了就是真金白银或合规事故,抽出来 + 写 PHPUnit 集成测试锁死不变量。 + +## 五、测试数据库安全规范 + +### 强制使用专用测试库 + +所有 PHPUnit 测试**必须**连专用测试库,**禁止**连 `.env` 配置的开发/生产库。 + +测试库连接策略(参数化,复用开发库同实例): + +| 项 | 值 | +|----|----| +| host / port / username / password / charset / prefix | **从 .env 读取**(复用开发库同实例的连接参数) | +| database | `.env` 中 `DATABASE` 值 + `_test` 后缀(如 `ulthon_admin` → `ulthon_admin_test`) | + +这样单机 MySQL 只需建一个同实例的 `_test` 库,无需额外 Docker 容器。覆盖实现在 `tests/bootstrap.php`(config 级覆盖 `connections.main`)。 + +### 第一道安全闸门 + +`base\common\test\TestCaseBase::setUp()` 的第一步是 `assertTestDatabase()`:读当前连接的库名,若不含子串 `test`(大小写不敏感),立即 `fail()` 中止测试。这保证即使 `tests/bootstrap.php` 的 config 覆盖写错、或 `.env` 被误改指向生产库,测试也绝不会对生产库写入任何数据。 + +`ulthon_admin_test` 含 `test`,放行;远程生产库(不含 `test`)拦截。 + +### 测试库初始化 + +用 `tests/setup_test_db.php` 初始化表结构(参数化,复用 .env 连接 + database=`_test`)。脚本内部跑 `migrate:run`(系统表)+ `scheme:sync --force-force`(业务表)+ `seed:run`(参考数据),幂等可重复执行。详见 `tests/README.md`。 + +### 为什么用 config 级覆盖而不是环境变量 + +ThinkPHP 的 `Env` 组件加载 `.env`,且 `.env` 值【优先于】OS 环境变量(`getenv` 仅作为 `.env` 中不存在键的 fallback)。因此 `putenv('DATABASE=...')`、`$env:DATABASE=...`、bash 前缀变量都**无法**覆盖 `.env` 里已存在的连接键。唯一可靠覆盖点是 config 级:在 `App::initialize()` 之后直接改写 `config/database.php` 解析出的连接数组。 + +关键坑点(think 8):`Config::set(array, $name)` 的第二参数只支持单段一级名,不像 `Config::get` 那样解析点号路径。正确做法是把整段 `database` 配置 pull 出来,原地改 `connections.main`,再 `Config::set($dbConfig, 'database')` 写回。 + +## 六、fixture 使用规范(通用原则) + +### 继承应用层入口 + +业务测试用例继承 `app\common\test\TestCase`(继承自内核 `base\common\test\TestCaseBase`)。**不要**直接继承 `TestCaseBase`(那是内核层),也不要直接继承 `PHPUnit\Framework\TestCase`(拿不到 fixture 工厂和事务隔离)。 + +### 事务自动回滚(无需手动清理) + +`TestCaseBase::setUp()` 开启事务,`tearDown()` 自动 `Db::rollback()` 撤销本测试的一切 DB 写入。**无需手动 truncate 或 delete**。 + +事务 API(think-orm 3.0,实测确认):`Db::startTrans()` 开启 / `Db::rollback()` 回滚 / `Db::commit()` 提交。注意**没有** `rollbackTrans` / `commitTrans` 这两个方法。 + +### Db::name vs Model 选择原则 + +造 fixture 数据时,默认用 `Model::create()`(走模型,享受自动时间戳等特性)。但遇到这种情况要回退 `Db::name()`: + +- 表的 Scheme **没有** `update_time` 列,但模型继承 `TimeModel`(`autoWriteTimestamp=true`,`updateTime='update_time'`),用 `Model::create` 会因 `fields_strict=true` 写不存在的列而抛错。 +- 此时走 `Db::name($table)->insertGetId($data)` 显式只写表里实际存在的列,再 `Model::find($id)` 回查返回模型实例保持类型一致。 + +这是"model 不适用就回退 Db::name"的典型场景,不是偷懒,是绕开 ORM 与表结构不匹配的必要手段。 + +### 软删除 fixture + +需要造软删数据时,用 `Db::name($table)->where('id', $id)->update(['delete_time' => time()])` 绕开 `SoftDelete` trait 的拦截复杂度,对任意模型类型确定生效。不要试图通过模型实例触发软删(trait 的拦截逻辑在不同模型配置下行为不一)。 + +### 业务 fixture 工厂由各项目自行实现 + +框架层(`TestCaseBase`)只提供通用基建: + +- 事务回滚隔离(`setUp` / `tearDown`) +- DB 断言(`assertDatabaseHas` / `assertDatabaseMissing`) +- 隔离探针(`assertIsolationWorks`) +- Plan B(`truncateTables`,事务回滚失效时的降级) + +**不提供**业务 fixture 工厂(如 `createUser` / `createOrder` 等)。各衍生项目在自己的 `app/common/test/TestCase`(继承 `TestCaseBase`)中实现业务工厂方法。工厂方法应运行在 `setUp` 事务内,`tearDown` 自动回滚,无需手动清理。 + +## 七、自查清单 + +写测试或动 service 前,对照本规则自查: + +- [ ] 这段逻辑错了后果可承受吗?(第一节 1.4 + 第二节决策树)后果可承受且只用一次,留在控制器 + `tools:http:call`,不要硬抽 service。 +- [ ] 如果错了不可承受,抽成 service 了吗?配 PHPUnit 集成测试了吗?(第三节"核心 service"层) +- [ ] 测试继承 `app\common\test\TestCase` 了吗?连的是 `_test` 库吗?(第五节) +- [ ] fixture 用工厂方法了吗?没有手动 truncate 吧?(第六节) +- [ ] 致命约束的不变量用测试锁死了吗?(不只是覆盖当前行为,要锁死"不能怎样"的约束) diff --git a/.agents/skills/ulthon-testing/SKILL.md b/.agents/skills/ulthon-testing/SKILL.md new file mode 100644 index 0000000..b5312aa --- /dev/null +++ b/.agents/skills/ulthon-testing/SKILL.md @@ -0,0 +1,278 @@ +--- +name: "ulthon-testing" +description: "框架测试工作流:如何运行测试、编写新测试(继承/fixture/事务回滚)、选择测试类型、设置测试环境、验证回归保护。需要跑 phpunit 或给致命约束/状态流转逻辑加测试时调用。" +--- + +# 测试工作流(运行 + 编写 + 决策) + +本技能是框架级(`ulthon-` 前缀)的测试入门指南。读完这一篇,新开发者或 AI 就能:把测试跑起来、照模板写一个新测试、选对测试工具、设置测试环境。 + +测试栈基线:PHPUnit 10.5.x(`composer.json` 里 `phpunit/phpunit: ^10`),ThinkPHP 8 容器,参数化测试库(复用 .env 连接,database = env+'_test'),事务回滚做隔离。 + +## 何时调用 + +- 第一次在这个项目跑测试,不知道怎么准备环境。 +- 要给一段状态流转、致命约束、软删除相关的 service 逻辑加回归测试。 +- 拿到一个 bug,想先用测试锁住复现路径再修。 +- 不确定该写 phpunit 还是直接用 `tools:http:call`。 + +## 1. 如何运行测试 + +### 1.1 首次准备(建测试库 + 建表,只做一次) + +测试库复用开发库同实例的连接参数(host/port/user/pass 从 .env 读),仅库名不同(= .env 的 `DATABASE` + `_test`)。单机 MySQL 建一个 `_test` 库即可,无需专用 Docker 容器。 + +第一步,建测试库(一次性,用 root 连接): + +```sql +CREATE DATABASE IF NOT EXISTS ulthon_admin_test + CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; +``` + +(库名 = .env 的 `DATABASE` 值 + `_test`,如 `ulthon_admin` → `ulthon_admin_test`) + +第二步,建表 + 种子(测试库已建后执行): + +```bash +php tests/setup_test_db.php +``` + +这个脚本会在同进程内依次跑 `migrate:run`(系统表)、`scheme:sync`(业务表)、`seed:run`(参考数据),并自查 `information_schema`。成功退出码 0。脚本幂等,可安全重跑。 + +### 1.2 日常运行 + +全量跑(所有测试套件): + +```bash +# Linux / macOS / Git Bash +vendor/bin/phpunit + +# Windows PowerShell / cmd +vendor\bin\phpunit +``` + +跑单个测试文件: + +```bash +vendor/bin/phpunit tests/Feature/IsolationSmokeTest.php +``` + +只跑某个测试方法(`--filter` 匹配方法名片段): + +```bash +vendor/bin/phpunit --filter test_isolation +``` + +按套件跑(`phpunit.xml.dist` 定义了 testsuite,具体名称以该文件为准): + +```bash +vendor/bin/phpunit --testsuite feature +``` + +注意:Windows 下路径用反斜杠 `vendor\bin\phpunit`,Git Bash / WSL 下用正斜杠。 + +## 2. 如何编写新测试 + +### 2.1 继承应用层入口 + +集成测试一律继承 `app\common\test\TestCase`。不要直接继承 `base\common\test\TestCaseBase`(那是内核层),也不要直接继承 `PHPUnit\Framework\TestCase`(拿不到事务隔离和 DB 断言)。 + +```php +namespace tests\Integration; + +use app\common\test\TestCase; + +class XxxTest extends TestCase +{ + // ... +} +``` + +继承之后,基类自动帮你做两件事,你完全不用操心: + +- `setUp`:先校验当前连的是测试库(库名含 `test` 才放行,拒绝连生产库),再 `Db::startTrans()` 开事务。 +- `tearDown`:`Db::rollback()` 回滚事务,撤销本测试的一切 DB 写入。 + +事务 API 是 `Db::startTrans()` 开启、`Db::rollback()` 回滚(think-orm 3.0 没有 `rollbackTrans` 这个方法)。这两个调用都在基类里,测试作者不要自己调。 + +### 2.2 造数据(fixture) + +框架层(`TestCaseBase`)**不提供**业务 fixture 工厂(如 `createUser` / `createOrder`)。各衍生项目在自己的 `app\common\test\TestCase` 里实现业务工厂方法。工厂方法应: + +- 运行在 `setUp` 事务内,`tearDown` 自动回滚,无需手动清理。 +- 默认用 `Model::create()`;遇到表无 `update_time` 列但模型继承 `TimeModel` 时,回退 `Db::name()->insertGetId()`(见规则第六节"Db::name vs Model")。 +- 造软删数据用 `Db::name($table)->where('id', $id)->update(['delete_time' => time()])`,绕开 `SoftDelete` trait。 + +### 2.3 断言 + +PHPUnit 原生断言照常用(`assertTrue` / `assertFalse` / `assertEquals` / `assertStringContainsString` 等)。基类额外提供两个 DB 断言助手,参数都是逻辑表名(不含前缀,和 `Db::name()` 一致): + +```php +$this->assertDatabaseHas('system_admin', ['username' => 'admin']); +$this->assertDatabaseMissing('system_admin', ['username' => 'deleted_user']); +``` + +断言失败时,建议在 message 里带上被测方法的诊断信号。例如 service 返回的 `reason` 文案是它唯一的诊断出口,拼进 message 能立刻看出是哪道校验拒绝了: + +```php +$this->assertFalse($result['ok'], 'reason: ' . ($result['reason'] ?? '')); +``` + +### 2.4 隔离(无需手动清理) + +事务回滚是 PRIMARY 隔离手段。每个测试开事务、结束时回滚,数据不跨测试残留。不要在测试里手动 `DELETE` / `TRUNCATE` 清数据。 + +如果怀疑隔离失效(比如某个测试改了框架级单例、或跑了自动提交的 DDL),可以调用基类的 `assertIsolationWorks()` 做自验证(三步探针:先断言探针行不存在,插入,再断言可见;下一个测试的第一步如果失败就说明上一个测试没回滚干净)。极端降级方案是 `truncateTables(array $tables)`,但正常情况下用不到。 + +## 3. 测试模板 + +### 3.1 单元测试模板(纯函数 / 算法,无需 DB) + +纯函数测试**不要**继承 `app\common\test\TestCase`(那会触发 setUp 连测试库、开事务,拖慢且强依赖容器)。直接继承 `PHPUnit\Framework\TestCase`: + +```php +assertSame('expected', $result); + } +} +``` + +放进 `tests/Unit/`,namespace 用 `tests\Unit`。 + +### 3.2 集成测试模板(service / DB 写入 / 状态流转) + +集成测试继承 `app\common\test\TestCase`,用 fixture 造数据、调 service、断言: + +```php +createUser(['balance' => 100]); + + // 2. 调被测逻辑 + $result = XxxService::deduct($user, 30); + + // 3. 断言(原生 + DB 断言) + $this->assertTrue($result['ok']); + $this->assertDatabaseHas('user', ['id' => $user->id, 'balance' => 70]); + } + + public function test_invariant_locked(): void + { + // 锁死不变量:余额不能扣成负数 + $user = $this->createUser(['balance' => 10]); + + $result = XxxService::deduct($user, 100); + + $this->assertFalse($result['ok']); + $this->assertDatabaseHas('user', ['id' => $user->id, 'balance' => 10]); // 余额未变 + } +} +``` + +放进 `tests/Integration/`,namespace 用 `tests\Integration`。 + +## 4. 何时写哪种测试(决策表) + +不是所有验证都要写 phpunit。选错工具会浪费时间。下表交叉引用了规则文件 `.agents/rules/ulthon-testing.md` 的决策树。 + +| 被测对象 | 用什么 | 放哪 | 原因 | +|----------|--------|------|------| +| 纯函数 / 算法 / 格式化 | phpunit 单元测试 | `tests/Unit/` | 无副作用,跑得快,不依赖容器 | +| 致命约束 / 状态流转 service(余额扣减、状态机、软删历史、绑定约束) | phpunit 集成测试 | `tests/Integration/` | 要造 DB 前置、断言历史记录,事务回滚保证隔离 | +| 控制器列表 / 表单 / 筛选 / 增删改查链路 | `tools:http:call` | 不写 phpunit | 模拟登录请求即可验证,写 phpunit 成本高收益低 | +| 并发临界区 / 竞态(抢购抢锁等) | 独立并发脚本 | `runtime/agents/` 或临时脚本 | phpunit 单进程模拟不了真并发,要起多进程 | + +一句话:**能复现就写测试,只是跑一次看返回就用 tools:http:call**。致命约束和状态流转必须用 phpunit 锁住,因为这类 bug 一旦上线就是事故级别。 + +## 5. 如何设置测试环境 + +完整步骤(首次或重装时执行): + +1. **装依赖**:`composer install`(`phpunit/phpunit: ^10` 已在 require-dev)。 +2. **建测试库**:用 root 连接执行 `CREATE DATABASE {env.database}_test CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;`(库名 = .env 的 `DATABASE` + `_test`)。 +3. **建表 + 种子**:`php tests/setup_test_db.php`。 + +为什么建表必须跑两套机制(这是框架最重要的特点之一,缺一不可): + +| 机制 | 命令 | 来源目录 | 建什么 | +|------|------|----------|--------| +| Phinx 迁移 | `migrate:run` | `database/migrations/` | 系统表(system_admin / system_menu / system_auth_node / system_config / system_host / system_timer_* 等) | +| Scheme 同步 | `scheme:sync` | `app/admin/scheme/` | 业务表(各项目自己用 PHP 8 Attribute 声明的表) | + +只跑 migrate 拿不到业务表,只跑 scheme 拿不到系统表(框架无法登录鉴权)。`setup_test_db.php` 在同进程内按顺序调这三条命令(migrate:run / scheme:sync / seed:run),并对每条带 `--force-force` 跳过交互确认(非 TTY 下 `scheme:sync` 会因 confirm 抛 Aborted)。 + +为什么用 config 级覆盖而不是环境变量:ThinkPHP 的 Env 加载 `.env`,且 `.env` 值优先于 OS 环境变量。`putenv('DATABASE=...')`、`$env:DATABASE=...`、bash 前缀变量都无法覆盖 `.env` 里已有的连接键。唯一可靠覆盖点是 config 级,在 `App::initialize()` 之后改写 `config/database.php` 解析出的连接数组。`tests/bootstrap.php` 和 `tests/setup_test_db.php` 都用这个模式。 + +注意一个 think 8 的坑:`Config::set(array, $name)` 的第二参数只支持单段一级名,不像 `Config::get` 那样解析点号路径。正确写法是把整段 `database` 配置 pull 出来,原地改 `connections.main`,再 `Config::set($dbConfig, 'database')` 写回。 + +彻底重置测试库: + +```bash +mysql -uroot -proot -e "DROP DATABASE IF EXISTS ulthon_admin_test; \ + CREATE DATABASE ulthon_admin_test CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;" +php tests/setup_test_db.php +``` + +## 6. 如何验证回归保护(破坏 + 恢复法) + +写完一个不变量级测试后,要确认它真的能抓住回归。光跑绿不够,因为一个永远绿的测试可能是空壳。用"破坏 + 恢复法"五步验证: + +1. **基线绿**:先跑测试,确认全绿。这是起点。 +2. **临时破坏**:把生产代码里被锁的逻辑临时改错。例如把"余额不能扣成负数"的校验临时注释掉,或把"拒绝重复绑定"改成"允许"。 +3. **跑测试变红**:再跑测试,相关用例应该失败。如果这步还是绿,说明测试没锁住这个约束,白写了,回去补用例。 +4. **回退代码**:`git checkout -- app/common/service/XxxService.php`(或对应文件),恢复生产代码原状。 +5. **恢复绿**:再跑测试,确认全绿。证明破坏是测试抓到的,不是环境问题。 + +这五步走完,这个测试的回归保护才算真的成立。之后任何人改这段代码,CI 会替你拦住。 + +## 相关文件 + +| 文件 | 作用 | +|------|------| +| `tests/bootstrap.php` | PHPUnit 引导:autoload + 容器初始化 + config 级覆盖到测试库 | +| `tests/setup_test_db.php` | 测试库建表脚本:migrate:run + scheme:sync + seed:run + 自验证 | +| `tests/setup_test_db.ps1` / `.sh` | Windows / Linux 包装器 | +| `tests/README.md` | 测试库建库与建表的完整说明 | +| `phpunit.xml.dist` | PHPUnit 配置:testsuite 定义,bootstrap 指向 tests/bootstrap.php | +| `extend/base/common/test/TestCaseBase.php` | 内核层测试基类:安全校验、事务隔离、DB 断言、探针 | +| `app/common/test/TestCase.php` | 应用层入口:继承 TestCaseBase,各项目在此实现业务 fixture 工厂 | +| `tests/Feature/IsolationSmokeTest.php` | 隔离机制冒烟测试(探针回滚 + 拒绝非测试库) | + +交叉参考:测试类型决策与设计哲学见 `.agents/rules/ulthon-testing.md`(规则文件);控制器联调验证见 [ulthon-tools-http-call](./ulthon-tools-http-call/SKILL.md);Base/App 架构分层见 [ulthon-base-app-architecture](./ulthon-base-app-architecture/SKILL.md)。 diff --git a/AGENTS.md b/AGENTS.md index 8fc724a..ae9ade6 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -101,6 +101,7 @@ | [ulthon-deploy-environment.md](./.agents/rules/ulthon-deploy-environment.md) | 部署与命令执行 | 部署栈模式与 Docker/宿主机命令判断 | | [ulthon-source-directory.md](./.agents/rules/ulthon-source-directory.md) | source/ 目录 | 子项目/多端代码的目录约定与安全要求 | | [ulthon-timer-multi-node.md](./.agents/rules/ulthon-timer-multi-node.md) | 定时任务相关 | 多节点协调规则与设计 | +| [ulthon-testing.md](./.agents/rules/ulthon-testing.md) | 测试规范 | 测试设计哲学、4层测试模型、该不该写测试、测试库安全、fixture 原则 | > 使用者业务规则索引见 `.agents/PROJECT.md` 的「规则索引」章节。 @@ -121,6 +122,7 @@ Skills 是"按场景调用的工作流说明",统一以 `.agents/skills/*/SKIL - 登录认证(Session + Token):[ulthon-auth-session-token](./.agents/skills/ulthon-auth-session-token/SKILL.md) - 权限与角色管理(RBAC CLI):[ulthon-permission-cli](./.agents/skills/ulthon-permission-cli/SKILL.md) - 菜单管理(admin:menu:\* CLI):[ulthon-admin-menu-cli](./.agents/skills/ulthon-admin-menu-cli/SKILL.md) +- 测试工作流(运行/编写/决策/回归保护):[ulthon-testing](./.agents/skills/ulthon-testing/SKILL.md) ## 智能体指导 diff --git a/tests/README.md b/tests/README.md new file mode 100644 index 0000000..2ba4c88 --- /dev/null +++ b/tests/README.md @@ -0,0 +1,176 @@ +# 测试数据库初始化 + +本目录提供测试数据库(默认 `ulthon_admin_test`)的初始化脚本。运行一次即可在与 `.env` +同实例的 MySQL 上建出框架运行所需的**全部表**(系统表 + 业务表),并插入最小参考数据。 + +## 测试库策略:参数化,无需专用 Docker 容器 + +ulthon 是框架仓库,测试库策略**与衍生项目(wkbox)不同**: + +- **完全参数化**:连接参数(hostname / hostport / username / password / charset / + prefix / database)全部从 `.env` 读取,复用开发库同实例的 MySQL。 +- **不依赖 Docker**:单机装一个 MySQL 即可,不需要为测试专起容器。 +- **测试库名派生规则**:`.env` 的 `DATABASE` + `_test` 后缀。如 `DATABASE=ulthon_admin` + 则测试库为 `ulthon_admin_test`;若 `.env` 改成别的项目名,测试库名会跟着变。 +- **脚本自带建库**:用 PDO 直连 MySQL 执行 `CREATE DATABASE IF NOT EXISTS`,测试库 + 不存在会自动创建(utf8mb4 / utf8mb4_unicode_ci),已存在则幂等跳过。 + +> 衍生项目(如 wkbox)如果偏好专用 Docker 容器做物理隔离,可以 fork 本脚本,把 PDO +> 建库段换成 `docker exec ... mysql -e "..."`,并把连接参数改成硬编码容器端口—— +> 但 ulthon 框架自身保持参数化、零容器假设。 + +## 文件说明 + +| 文件 | 作用 | +|------|------| +| `setup_test_db.php` | 真正的运行器(PHP)。启动 ThinkPHP 容器、读 `.env`、PDO 建测试库、config 级覆盖连接、按序调用 `migrate:run` / `scheme:sync` / `seed:run`、查 `information_schema` 自验证。 | +| `setup_test_db.ps1` | Windows / PowerShell 包装器:检测 php 在 PATH → 调 PHP 运行器 → 透传退出码。 | +| `setup_test_db.sh` | bash / CI 包装器:逻辑同上,Linux / macOS / CI 用。 | +| `bootstrap.php` | PHPUnit 引导脚本(已存在,独立维护)。连接覆盖逻辑与本脚本完全一致,二者必须保持同步——否则 PHPUnit 跑的测试库与本脚本初始化的不是同一个。 | + +## 如何运行 + +```powershell +# Windows / PowerShell +powershell -File tests/setup_test_db.ps1 +# 或直接跑运行器 +php tests/setup_test_db.php +``` + +```bash +# Linux / macOS / CI +bash tests/setup_test_db.sh +# 或 +php tests/setup_test_db.php +``` + +成功后测试库(如 `ulthon_admin_test`)会包含框架所需的系统表(`ul_system_*`)以及 +`app/admin/scheme/` 下声明的业务表,退出码为 `0`。脚本自带验证段,会打印表总数并按 +`system` / `app_` / 其它分类,并断言关键系统表存在。 + +## 关键概念:migrate:run 与 scheme:sync 两套机制,缺一不可 + +ulthon 的建表有**两个独立的来源**,二者不能互相替代: + +| 机制 | 命令 | 来源目录 | 建什么表 | 举例 | +|------|------|----------|----------|------| +| Phinx 迁移 | `php think migrate:run` | `database/migrations/` | 系统 / 框架基础表 | `ul_system_admin`、`ul_system_menu`、`ul_system_auth_node`、`ul_system_config`、`ul_system_host`、`ul_system_timer_*`、`ul_debug_log` 等 | +| Scheme 同步 | `php think scheme:sync` | `app/admin/scheme/` | 业务表(用 PHP 8 Attribute 声明) | 框架仓库本身不带业务表,使用方按需在 `app/admin/scheme/` 添加 | + +- **只跑 `migrate:run`**:得到系统表,但没有业务表。 +- **只跑 `scheme:sync`**:得到业务表,但没有系统表,框架无法登录、无法鉴权。 +- **必须两个都跑**,业务表通常引用系统表(如外键关联 `system_admin`)。 + +`seed:run` 负责最小参考数据(超级管理员、权限节点、系统设置、菜单、快捷入口等),由 +`database/seeds/InitBaseAdminData.php` 提供,自带 install-lock,重复执行安全跳过。 + +本脚本在**同一进程**内依次调用这三条命令(见下节),确保它们都指向测试库。 + +## 技术细节:为什么用 config 级覆盖,而不是环境变量 + +ThinkPHP 的 `Env` 组件加载 `.env` 文件,且 **`.env` 的值优先于 OS 环境变量** +(`getenv` 仅作为 `.env` 中不存在键的 fallback)。因此下列方式都**无法**覆盖 `.env` +里已存在的连接键(`HOSTNAME` / `DATABASE` / `USERNAME` / `PASSWORD` / `HOSTPORT` 都在 +`.env` 中): + +- `putenv('DATABASE=ulthon_admin_test')`(PHP) +- `$env:DATABASE='ulthon_admin_test'`(PowerShell) +- `DATABASE=ulthon_admin_test php think migrate:run`(bash 前缀变量) + +**唯一可靠**的覆盖点是 **config 级**:在 `App::initialize()` 之后,直接改写 +`config/database.php` 解析出的连接配置数组。因为 `Db` facade 运行时读 config, +config 被改写后,后续所有 DB 操作(包括 `migrate:run` / `scheme:sync` / `seed:run` +内部)都连到测试库。 + +`setup_test_db.php` 的核心步骤: + +```php +require __DIR__ . '/../vendor/autoload.php'; +$app = new \think\App(); +$app->initialize(); + +// 从 .env 读连接参数(与 bootstrap.php 完全一致) +$hostname = \think\facade\Env::get('database.hostname'); +// ...其它参数同理 +$testDatabase = \think\facade\Env::get('database.database') . '_test'; + +// PDO 直连(不指定 database),建测试库 +$pdo = new \PDO("mysql:host={$hostname};port={$hostport};charset={$charset}", $username, $password); +$pdo->exec("CREATE DATABASE IF NOT EXISTS `{$testDatabase}` CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci"); + +// 把整段 'database' 配置 pull 出来,原地改 connections.main,再 set 回去。 +// 注意:think\Config::set(array, $name) 的 $name 只支持单段一级名, +// 不像 Config::get 那样解析点号多级路径——不能直接传 'database.connections.main'。 +$dbConfig = \think\facade\Config::get('database'); +$dbConfig['default'] = 'main'; +$dbConfig['connections']['main'] = array_merge( + $dbConfig['connections']['main'] ?? [], + [ + 'hostname' => $hostname, 'hostport' => $hostport, + 'database' => $testDatabase, 'username' => $username, 'password' => $password, + 'charset' => $charset, 'prefix' => $prefix, 'fields_cache' => false, + ], + ['query' => \app\common\provider\db\Query::class], // 必须保留框架自定义查询类 +); +\think\facade\Config::set($dbConfig, 'database'); + +// 安全护栏:库名必须含 'test' 才允许继续 +if (stripos(\think\facade\Config::get('database.connections.main.database'), 'test') === false) { + throw new \RuntimeException('拒绝执行:当前库不像测试库'); +} + +// 同进程内调用 think 命令,config 覆盖对它们同样生效 +\think\facade\Console::call('migrate:run', ['--force-force']); +\think\facade\Console::call('scheme:sync', ['--force-force']); // --force-force 跳过交互确认 +\think\facade\Console::call('seed:run', ['--force-force']); +``` + +### 关于 `--force-force`(`-ff`) + +`scheme:sync` 在检测到 Scheme 与 DB 有差异时会**交互式确认**("确认要将这些变更应用到 +数据库吗?")。非 TTY 环境(脚本、CI)下 `think\console\Output::confirm` 会抛 +"Aborted" 而失败。框架提供了全局 `--force-force` / `-ff` 选项(见 +`extend/base/common/provider/ConsoleBase.php`、`extend/base/common/console/OutputBase.php`), +命中时 `confirm()` 直接返回默认值,跳过所有交互确认。本脚本对三条命令统一带上该参数, +保证非交互可运行、可重跑。 + +### 关于默认连接名 `main` + +本项目 `config/database.php` 的默认连接是 `main`(不是 ThinkPHP 常见的 `mysql`), +`default = Env::get('database.main')`,`.env` 里 `MAIN=main`。因此覆盖目标是 +`connections.main`,**不是** `connections.mysql`。 + +## 幂等性 + +脚本可安全重复执行: + +- `migrate:run`:已执行的迁移记录在 `ul_migrations`(phinxlog),二次运行直接 "All Done" 无操作。 +- `scheme:sync`:无差异时打印 "未检测到 Scheme 变更";有差异时先备份原表(`ul_backup_<时间戳>_<表名>`)再改,不报错。 +- `seed:run`:`InitBaseAdminData` 自带 install-lock(`base_admin_install` 配置项),二次运行打印 "系统已初始化,跳过当前程序"。 +- `CREATE DATABASE IF NOT EXISTS`:库已存在时无操作。 + +> 注:`scheme:sync` 每次遇到差异都会生成 `ul_backup_*` 备份表,这是框架的设计行为 +> (改表结构前自动备份)。若测试库中积累了大量备份表,可直接 `DROP DATABASE` + +> `CREATE DATABASE` 后重跑本脚本重建(见下)。 + +## 彻底重置测试库 + +当测试库状态污染、备份表堆积、或想从干净状态重新开始时: + +```bash +# 用 .env 里的 root 账号连本机 MySQL(按实际 .env 替换 host/port/user/pass) +mysql -h127.0.0.1 -P3306 -uroot -proot \ + -e "DROP DATABASE IF EXISTS ulthon_admin_test; \ + CREATE DATABASE ulthon_admin_test CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;" +# 然后重跑初始化 +php tests/setup_test_db.php +``` + +PowerShell 等价: + +```powershell +mysql -h127.0.0.1 -P3306 -uroot -proot ` + -e "DROP DATABASE IF EXISTS ulthon_admin_test; ` + CREATE DATABASE ulthon_admin_test CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;" +php tests/setup_test_db.php +``` diff --git a/tests/setup_test_db.php b/tests/setup_test_db.php new file mode 100644 index 0000000..14778be --- /dev/null +++ b/tests/setup_test_db.php @@ -0,0 +1,212 @@ +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::call,config 覆盖对命令同样生效) +// Console::call 返回 think\console\Output,命令输出被写进其私有 handle(Buffer)。 +// 用反射取出 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/)'], + // 参考数据(seed):InitBaseAdminData,自带 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); diff --git a/tests/setup_test_db.ps1 b/tests/setup_test_db.ps1 new file mode 100644 index 0000000..306f0e1 --- /dev/null +++ b/tests/setup_test_db.ps1 @@ -0,0 +1,46 @@ +<# +.SYNOPSIS + Test database setup (Windows / PowerShell wrapper). + +.DESCRIPTION + Calls the PHP runner tests/setup_test_db.php, which in a single process: + 1. reads connection params from .env (hostname/hostport/username/password/ + charset/prefix/database) + 2. derives the test DB name as {env.database} + '_test' + (e.g. ulthon_admin -> ulthon_admin_test) + 3. CREATE DATABASE IF NOT EXISTS on the same MySQL instance (no Docker) + 4. config-level override connections.main -> test DB + 5. php think migrate:run -> system/framework tables (database/migrations/) + 6. php think scheme:sync -> business tables (app/admin/scheme/) + 7. php think seed:run -> reference data (database/seeds/) + 8. queries information_schema to verify the tables landed in the test DB + + Fully parameterized: no hardcoded host/port/credentials. The MySQL instance + is whatever .env points at (typically a single local MySQL). + +.NOTES + ASCII-only on purpose: PowerShell 5.1 mis-decodes BOM-less UTF-8 CJK. +#> + +# Runner lives next to this script. +$Runner = Join-Path $PSScriptRoot 'setup_test_db.php' + +# 1. Ensure php is on PATH. +$phpCmd = Get-Command php -ErrorAction SilentlyContinue +if (-not $phpCmd) { + Write-Host "[ERROR] 'php' not found on PATH. Install PHP 8+ and add it to PATH." -ForegroundColor Red + exit 1 +} + +# 2. Run the PHP runner. +Write-Host "==== Running setup_test_db.php ====" -ForegroundColor Cyan +& php $Runner +$code = $LASTEXITCODE + +Write-Host "" +if ($code -eq 0) { + Write-Host "==== Done. PHP exit code = $code ====" -ForegroundColor Green +} else { + Write-Host "==== Done. PHP exit code = $code ====" -ForegroundColor Red +} +exit $code diff --git a/tests/setup_test_db.sh b/tests/setup_test_db.sh new file mode 100644 index 0000000..b089d9b --- /dev/null +++ b/tests/setup_test_db.sh @@ -0,0 +1,35 @@ +#!/usr/bin/env bash +# 测试数据库初始化(bash / CI 包装器)。 +# +# 调用 PHP 运行器 tests/setup_test_db.php,在同一进程内: +# 1. 从 .env 读连接参数(hostname/hostport/username/password/charset/prefix/database) +# 2. 派生测试库名 = {env.database} + '_test'(如 ulthon_admin -> ulthon_admin_test) +# 3. 在同一 MySQL 实例上 CREATE DATABASE IF NOT EXISTS(无需专用 Docker 容器) +# 4. config 级覆盖 connections.main 指向测试库 +# 5. php think migrate:run 建系统/框架表(database/migrations/) +# 6. php think scheme:sync 建业务表(app/admin/scheme/) +# 7. php think seed:run 插参考数据(database/seeds/) +# 8. 查 information_schema 验证表已落到测试库 +# +# 完全参数化:不硬编码 host/port/credentials。MySQL 实例 = .env 指向的那个。 +# 详见 tests/README.md。 +set -euo pipefail + +# 项目根目录(脚本位于 tests/ 下,取上一级) +PROJECT_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +RUNNER="$PROJECT_ROOT/tests/setup_test_db.php" + +# 1. 确保 php 在 PATH 上 +if ! command -v php >/dev/null 2>&1; then + echo "[错误] PATH 上找不到 php。请安装 PHP 8+ 并加入 PATH。" + exit 1 +fi + +# 2. 运行 PHP 运行器 +echo "==== 运行 setup_test_db.php ====" +php "$RUNNER" +code=$? + +echo "" +echo "==== 完成,PHP 退出码 = $code ====" +exit "$code"