refactor(db): 增强测试事务支持和保存点管理
- 在 HoTimeDB 中新增测试事务功能,允许在测试模式下使用事务进行操作 - 实现 BeginTestTx 和 RollbackTestTx 方法,支持测试事务的开启和回滚 - 在 Action 方法中集成保存点管理,确保在测试模式下的嵌套事务处理 - 更新 README.md,添加 API 测试框架的相关说明,提升文档完整性
This commit is contained in:
parent
ff410b94c3
commit
93596ad0dc
299
.cursor/plans/api接口测试方案_05b9404e.plan.md
Normal file
299
.cursor/plans/api接口测试方案_05b9404e.plan.md
Normal file
@ -0,0 +1,299 @@
|
||||
---
|
||||
name: API接口测试方案
|
||||
overview: hotime 框架新增链式测试 API:数据在前方法在后,Post/Get 一步完成用例+断言,TestProj 合并注册,自动生成 Swagger UI。
|
||||
todos:
|
||||
- id: framework-testing-helper
|
||||
content: hotimev1.5/testing_helper.go:TestApp、NewTestApp、SetupForTest、TestRequest/WithSession、TestResponse
|
||||
status: pending
|
||||
- id: framework-testing-api
|
||||
content: hotimev1.5/testing_api.go:TestProj/ProjTest/CtrTest、Api(JSON/Query/Form/File/WithSession→Post/Get)、RunTests
|
||||
status: pending
|
||||
- id: framework-testing-swagger
|
||||
content: hotimev1.5/testing_swagger.go:GenerateSwagger 合并所有项目生成一份 Swagger
|
||||
status: pending
|
||||
- id: xbc-user-test
|
||||
content: xbc/app/user.go 末尾添加 UserTest,init.go 添加 ProjectTest,新增 app_test.go
|
||||
status: pending
|
||||
isProject: false
|
||||
---
|
||||
|
||||
# API 接口测试 + Swagger 文档自动生成(最终版)
|
||||
|
||||
## 一、最终链式设计:数据在前,方法在后
|
||||
|
||||
每条链以 `Post/Get/Put/Delete("描述", 期望status, [期望msg])` 结尾,这个终端调用同时完成:命名用例 + 发送请求 + 校验结果 + 收集文档。
|
||||
|
||||
```go
|
||||
// 数据 → 方法(终端操作)
|
||||
a.JSON(Map{"name": "138", "password": "123"}).Post("正常登录", 0)
|
||||
a.JSON(Map{"name": "138", "password": ""}).Post("密码为空", 4, "用户名或密码不能为空")
|
||||
a.Get("未登录", 2, "请先登录")
|
||||
a.Query(Map{"shop_id": "1"}).Get("查询列表", 0)
|
||||
a.WithSession(Map{"user_id": int64(1)}).Get("已登录", 0)
|
||||
a.Query(Map{"token": "abc"}).JSON(Map{"phone": "138"}).Post("混合传参", 3, "异常")
|
||||
a.File("file", "a.png", imgBytes).Post("上传文件", 0)
|
||||
```
|
||||
|
||||
### 对比之前的写法
|
||||
|
||||
```
|
||||
之前(三步):a.PostCase("正常登录").JSON(Map{...}).Test(0)
|
||||
之前(两步):a.Post("正常登录", Map{...}).Test(0)
|
||||
现在(一步):a.JSON(Map{...}).Post("正常登录", 0) ← 终端操作只有一个
|
||||
现在(零数据):a.Get("未登录", 2, "请先登录") ← 最简一行
|
||||
```
|
||||
|
||||
## 二、完整场景对照
|
||||
|
||||
```go
|
||||
// POST JSON(最常见)
|
||||
a.JSON(Map{"name": "138", "password": "123"}).Post("正常登录", 0)
|
||||
|
||||
// GET 无参数
|
||||
a.Get("未登录", 2, "请先登录")
|
||||
|
||||
// GET + URL 参数
|
||||
a.Query(Map{"shop_id": "1", "page": "1"}).Get("查询列表", 0)
|
||||
|
||||
// 需登录 + GET
|
||||
a.WithSession(Map{"user_id": int64(1)}).Get("已登录", 0)
|
||||
|
||||
// 需登录 + GET + 参数
|
||||
a.WithSession(Map{"user_id": int64(1)}).Query(Map{"shop_id": "1"}).Get("查询", 0)
|
||||
|
||||
// 需登录 + POST JSON
|
||||
a.WithSession(Map{"user_id": int64(1)}).JSON(Map{"name": "new"}).Post("更新", 0)
|
||||
|
||||
// 混合:URL 参数 + JSON body
|
||||
a.Query(Map{"token": "abc"}).JSON(Map{"phone": "138"}).Post("混合传参", 3, "异常")
|
||||
|
||||
// POST Form 表单
|
||||
a.Form(Map{"shop_id": "1", "id": "1"}).Post("表单提交", 0)
|
||||
|
||||
// 文件上传
|
||||
a.File("file", "a.png", imgBytes).Post("上传文件", 0)
|
||||
|
||||
// 文件 + 表单字段
|
||||
a.File("file", "a.png", imgBytes).Form(Map{"type": "avatar"}).Post("上传+表单", 0)
|
||||
|
||||
// PUT JSON
|
||||
a.JSON(Map{"name": "新名字"}).Put("更新信息", 0)
|
||||
|
||||
// DELETE + 参数
|
||||
a.Query(Map{"id": "1"}).Delete("删除", 0)
|
||||
|
||||
// 校验响应数据(status=0 时)
|
||||
resp := a.JSON(Map{"name": "138", "password": "123"}).Post("正常登录", 0)
|
||||
if resp.GetBody().GetMap("result").GetString("token") == "" {
|
||||
resp.Fail("缺少 token")
|
||||
}
|
||||
```
|
||||
|
||||
## 三、完整示例:user.go
|
||||
|
||||
```go
|
||||
var UserCtr = Ctr{
|
||||
"login": func(that *Context) { /* ... */ },
|
||||
"info": func(that *Context) { /* ... */ },
|
||||
"token": func(that *Context) { /* ... */ },
|
||||
"create": func(that *Context) { /* ... */ },
|
||||
"file": func(that *Context) { /* ... */ },
|
||||
}
|
||||
|
||||
// ========= 接口测试 =========
|
||||
var UserTest = CtrTest{
|
||||
"login": {"用户账号密码登录", func(a *Api) {
|
||||
a.JSON(Map{"name": "13800138000", "password": "123456"}).Post("正常登录", 0)
|
||||
a.JSON(Map{"name": "13800138000", "password": ""}).Post("密码为空", 4, "用户名或密码不能为空")
|
||||
a.JSON(Map{"name": "1380013", "password": "123456"}).Post("手机号格式错误", 4, "手机号码格式错误")
|
||||
a.JSON(Map{"name": "13800138000", "password": "wrong"}).Post("密码错误", 4, "用户名或密码错误")
|
||||
}},
|
||||
"info": {"获取用户信息", func(a *Api) {
|
||||
a.Get("未登录", 2, "请先登录")
|
||||
a.WithSession(Map{"user_id": int64(1)}).Get("已登录", 0)
|
||||
}},
|
||||
"token": {"获取会话Token", func(a *Api) {
|
||||
a.JSON(Map{"phone": "13800138000"}).Post("正确手机号", 0)
|
||||
a.JSON(Map{"phone": "138"}).Post("手机号格式错误", 3, "请输入正确的手机号")
|
||||
}},
|
||||
"create": {"用户注册", func(a *Api) {
|
||||
phone := "138" + ObjToStr(RandX(10000000, 99999999))
|
||||
defer a.DB().Delete("user", Map{"phone": phone})
|
||||
|
||||
a.JSON(Map{"phone": phone, "password": "123456"}).Post("正常注册", 0)
|
||||
a.JSON(Map{"phone": phone, "password": "123456"}).Post("重复注册", 3, "该手机号已被注册")
|
||||
a.JSON(Map{"phone": "138", "password": "123456"}).Post("手机号格式错误", 3, "请输入正确的手机号")
|
||||
a.JSON(Map{"phone": "13899999999", "password": "12"}).Post("密码太短", 3, "密码不能少于6位")
|
||||
}},
|
||||
"forget": {"忘记密码", func(a *Api) {
|
||||
a.JSON(Map{"phone": "13800138000"}).Post("无token", 3, "异常")
|
||||
a.Query(Map{"token": "abc"}).JSON(Map{"phone": "138", "code": "1234"}).Post("手机号错误", 3, "请输入正确的手机号")
|
||||
}},
|
||||
"file": {"文件上传", func(a *Api) {
|
||||
a.File("file", "test.txt", []byte("hello")).Post("未登录上传", 2, "你还没有登录")
|
||||
a.WithSession(Map{"user_id": int64(1)}).File("file", "test.txt", []byte("hello")).Post("已登录上传", 0)
|
||||
}},
|
||||
}
|
||||
```
|
||||
|
||||
## 四、TestProj 注册 + app_test.go
|
||||
|
||||
### init.go
|
||||
|
||||
```go
|
||||
var Project = Proj{
|
||||
"user": UserCtr,
|
||||
"goods": GoodsCtr,
|
||||
// ...
|
||||
}
|
||||
|
||||
var ProjectTest = ProjTest{
|
||||
"user": UserTest,
|
||||
"goods": GoodsTest,
|
||||
}
|
||||
```
|
||||
|
||||
### app_test.go
|
||||
|
||||
```go
|
||||
package app
|
||||
|
||||
import (
|
||||
"os"
|
||||
"testing"
|
||||
. "code.hoteas.com/golang/hotime"
|
||||
)
|
||||
|
||||
var testApp *TestApp
|
||||
|
||||
func TestMain(m *testing.M) {
|
||||
testApp = NewTestApp("../config/config.json", TestProj{
|
||||
"app": {Project, ProjectTest},
|
||||
})
|
||||
code := m.Run()
|
||||
testApp.GenerateSwagger("小帮菜 API", "2.1.0", testApp.Config.GetString("tpt"))
|
||||
os.Exit(code)
|
||||
}
|
||||
|
||||
func TestApi(t *testing.T) {
|
||||
testApp.RunTests(t)
|
||||
}
|
||||
```
|
||||
|
||||
## 五、Swagger:多项目合并为一份文档
|
||||
|
||||
`GenerateSwagger` 遍历 `TestProj` 中所有项目的所有测试数据,生成**一份合并的 OpenAPI JSON**。每个项目作为 Swagger 的 tag 分组:
|
||||
|
||||
```json
|
||||
{
|
||||
"tags": [
|
||||
{"name": "app/user", "description": "用户接口"},
|
||||
{"name": "app/goods", "description": "货品接口"},
|
||||
{"name": "customer/customer", "description": "客户端接口"}
|
||||
],
|
||||
"paths": {
|
||||
"/app/user/login": { ... },
|
||||
"/app/user/info": { ... },
|
||||
"/customer/customer/bindPhone": { ... }
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
多项目不会覆盖,全部汇聚在同一个文件中,按 tag 分组展示。
|
||||
|
||||
## 六、指定运行范围
|
||||
|
||||
Go 的 `-run` 参数支持按子测试层级过滤(`/` 分隔正则匹配):
|
||||
|
||||
```bash
|
||||
# 全部
|
||||
go test ./app/... -v
|
||||
|
||||
# 只跑 user 模块的所有接口
|
||||
go test ./app/... -v -run TestApi/user
|
||||
|
||||
# 只跑 user 模块的 login 接口
|
||||
go test ./app/... -v -run TestApi/user/login
|
||||
|
||||
# 只跑 login 接口的"密码为空"用例
|
||||
go test ./app/... -v -run TestApi/user/login/密码为空
|
||||
|
||||
# 跑 user 和 goods 两个模块
|
||||
go test ./app/... -v -run "TestApi/(user|goods)"
|
||||
|
||||
# 只跑所有模块中包含"未登录"的用例
|
||||
go test ./app/... -v -run "TestApi/.*/.*未登录"
|
||||
```
|
||||
|
||||
**不需要框架做任何额外处理**,Go 的 `t.Run()` 子测试机制天然支持。
|
||||
|
||||
## 七、框架类型设计
|
||||
|
||||
```go
|
||||
// === 注册类型 ===
|
||||
type TestProj map[string]TestProjDef
|
||||
type TestProjDef struct { Proj Proj; Tests ProjTest }
|
||||
type ProjTest map[string]CtrTest
|
||||
type CtrTest map[string]ApiTestDef
|
||||
type ApiTestDef struct { Desc string; Func func(a *Api) }
|
||||
|
||||
// === Api:起点,数据设置 ===
|
||||
type Api struct {
|
||||
app *TestApp
|
||||
path string
|
||||
t interface{}
|
||||
desc string
|
||||
session Map
|
||||
}
|
||||
|
||||
// 数据设置(返回 *ApiCase,开始构建)
|
||||
func (a *Api) JSON(body interface{}) *ApiCase
|
||||
func (a *Api) Query(params Map) *ApiCase
|
||||
func (a *Api) Form(body Map) *ApiCase
|
||||
func (a *Api) File(field, name string, content []byte) *ApiCase
|
||||
|
||||
// Session(返回新 *Api,后续调用都带此 Session)
|
||||
func (a *Api) WithSession(s Map) *Api
|
||||
|
||||
// 直接终端(无数据时直接调用)
|
||||
func (a *Api) Get(desc string, status int, msg ...string) *ApiResponse
|
||||
func (a *Api) Post(desc string, status int, msg ...string) *ApiResponse
|
||||
func (a *Api) Put(desc string, status int, msg ...string) *ApiResponse
|
||||
func (a *Api) Delete(desc string, status int, msg ...string) *ApiResponse
|
||||
|
||||
// 数据库
|
||||
func (a *Api) DB() *HoTimeDB
|
||||
|
||||
// === ApiCase:链式构建器 ===
|
||||
type ApiCase struct { /* api, session, query, jsonBody, formBody, file... */ }
|
||||
|
||||
// 继续叠加数据
|
||||
func (c *ApiCase) JSON(body interface{}) *ApiCase
|
||||
func (c *ApiCase) Query(params Map) *ApiCase
|
||||
func (c *ApiCase) Form(body Map) *ApiCase
|
||||
func (c *ApiCase) File(field, name string, content []byte) *ApiCase
|
||||
func (c *ApiCase) WithSession(s Map) *ApiCase
|
||||
|
||||
// 终端操作(HTTP 方法 + 用例名 + 断言)
|
||||
func (c *ApiCase) Get(desc string, status int, msg ...string) *ApiResponse
|
||||
func (c *ApiCase) Post(desc string, status int, msg ...string) *ApiResponse
|
||||
func (c *ApiCase) Put(desc string, status int, msg ...string) *ApiResponse
|
||||
func (c *ApiCase) Delete(desc string, status int, msg ...string) *ApiResponse
|
||||
|
||||
// === ApiResponse ===
|
||||
type ApiResponse struct { StatusCode int; Body Map; RawBody []byte }
|
||||
func (r *ApiResponse) GetStatus() int
|
||||
func (r *ApiResponse) GetResult() interface{}
|
||||
func (r *ApiResponse) GetMsg() string
|
||||
func (r *ApiResponse) GetBody() Map
|
||||
func (r *ApiResponse) Fail(msg string) // 自定义断言失败
|
||||
```
|
||||
|
||||
## 八、文件清单
|
||||
|
||||
- **新增** `d:/work/hotimev1.5/testing_helper.go`
|
||||
- **新增** `d:/work/hotimev1.5/testing_api.go`
|
||||
- **新增** `d:/work/hotimev1.5/testing_swagger.go`
|
||||
- **修改** `d:/work/xbc/app/user.go` -- 末尾添加 UserTest
|
||||
- **修改** `d:/work/xbc/app/init.go` -- 添加 ProjectTest
|
||||
- **新增** `d:/work/xbc/app/app_test.go`
|
||||
180
.cursor/plans/api测试框架完整实现_ce7fcbde.plan.md
Normal file
180
.cursor/plans/api测试框架完整实现_ce7fcbde.plan.md
Normal file
@ -0,0 +1,180 @@
|
||||
---
|
||||
name: API测试框架完整实现
|
||||
overview: 在 hotimev1.5 框架中实现完整的 API 测试基础设施:链式测试 API、事务回滚隔离、覆盖率追踪、Swagger 文档自动生成。
|
||||
todos:
|
||||
- id: db-testtx
|
||||
content: db/db.go:加 testTx 字段 + BeginTestTx/RollbackTestTx 方法
|
||||
status: completed
|
||||
- id: db-query
|
||||
content: db/query.go:Query(行64) 和 Exec(行120) 加 testTx 优先判断
|
||||
status: completed
|
||||
- id: db-transaction
|
||||
content: db/transaction.go:Action 加 testTx 传递 + SAVEPOINT 分支
|
||||
status: completed
|
||||
- id: testing-helper
|
||||
content: 新增 testing_helper.go:TestApp、NewTestApp、SetupForTest、RunTests(事务隔离)、PrintCoverage
|
||||
status: completed
|
||||
- id: testing-api
|
||||
content: 新增 testing_api.go:注册类型 + Api/ApiCase/ApiResponse 链式 API + TestCollector
|
||||
status: completed
|
||||
- id: testing-swagger
|
||||
content: 新增 testing_swagger.go:GenerateSwagger 合并所有项目生成 Swagger
|
||||
status: completed
|
||||
- id: xbc-integration
|
||||
content: xbc 业务层:user.go 加 UserTest、init.go 加 ProjectTest、新增 app_test.go
|
||||
status: completed
|
||||
isProject: false
|
||||
---
|
||||
|
||||
# API 测试框架完整实现
|
||||
|
||||
## 一、事务回滚隔离(db 层改造)
|
||||
|
||||
核心思路:给 `HoTimeDB` 增加 `testTx` 字段,优先级 `testTx > Tx > DB`。测试模式下 `Action()` 用 MySQL `SAVEPOINT` 代替真事务,保证嵌套事务都在外层测试事务内。
|
||||
|
||||
### 1. [db/db.go](d:/work/hotimev1.5/db/db.go) -- 加字段 + 方法
|
||||
|
||||
```go
|
||||
// HoTimeDB 结构体新增字段
|
||||
testTx *sql.Tx // 测试事务:设置后所有操作都在此事务内
|
||||
|
||||
// 新增两个方法
|
||||
func (that *HoTimeDB) BeginTestTx() error
|
||||
func (that *HoTimeDB) RollbackTestTx() error
|
||||
```
|
||||
|
||||
### 2. [db/query.go](d:/work/hotimev1.5/db/query.go) -- Query(行64) 和 Exec(行120) 各加一个判断
|
||||
|
||||
```go
|
||||
// 原:if that.Tx != nil { ... } else { ... }
|
||||
// 改:
|
||||
if that.testTx != nil {
|
||||
resl, err = that.testTx.Query(query, processedArgs...)
|
||||
} else if that.Tx != nil {
|
||||
resl, err = that.Tx.Query(query, processedArgs...)
|
||||
} else {
|
||||
resl, err = db.Query(query, processedArgs...)
|
||||
}
|
||||
// Exec 同理
|
||||
```
|
||||
|
||||
### 3. [db/transaction.go](d:/work/hotimev1.5/db/transaction.go) -- Action 加 SAVEPOINT 分支
|
||||
|
||||
```go
|
||||
func (that *HoTimeDB) Action(action func(db HoTimeDB) (isSuccess bool)) (isSuccess bool) {
|
||||
db := HoTimeDB{
|
||||
// ...现有字段拷贝...
|
||||
testTx: that.testTx, // 新增:传递 testTx
|
||||
}
|
||||
// 新增:测试模式用 SAVEPOINT
|
||||
if that.testTx != nil {
|
||||
spName := "sp_" + Md5(ObjToStr(RandX(100000, 999999)))[:8]
|
||||
_, _ = that.testTx.Exec("SAVEPOINT " + spName)
|
||||
db.Tx = that.testTx
|
||||
isSuccess = action(db)
|
||||
if !isSuccess {
|
||||
_, _ = that.testTx.Exec("ROLLBACK TO SAVEPOINT " + spName)
|
||||
} else {
|
||||
_, _ = that.testTx.Exec("RELEASE SAVEPOINT " + spName)
|
||||
}
|
||||
return isSuccess
|
||||
}
|
||||
// 以下原有逻辑不变...
|
||||
}
|
||||
```
|
||||
|
||||
**改动量:** 3 个文件,约 50 行,生产代码零影响(testTx 默认 nil)。
|
||||
|
||||
## 二、链式测试 API
|
||||
|
||||
### 4. 新增 [testing_helper.go](d:/work/hotimev1.5/testing_helper.go)
|
||||
|
||||
- `TestApp` 结构体(包含 `*Application`、`projs TestProj`、`collector *TestCollector`)
|
||||
- `NewTestApp(configPath, TestProj, listeners...)` -- 初始化 Application、注册路由,不启动 HTTP 服务
|
||||
- `SetupForTest(router Router)` -- 复用 `Run()` 的路由初始化逻辑
|
||||
- `TestRequest(method, path, body) TestResponse` / `TestRequestWithSession(...)` -- 通过 `httptest` 发请求
|
||||
- `TestResponse` 结构体
|
||||
- `RunTests(t)` -- 遍历 TestProj,每个方法级别 `BeginTestTx() + defer RollbackTestTx()`
|
||||
|
||||
### 5. 新增 [testing_api.go](d:/work/hotimev1.5/testing_api.go)
|
||||
|
||||
注册类型:
|
||||
|
||||
- `TestProj map[string]TestProjDef`
|
||||
- `TestProjDef struct { Proj Proj; Tests ProjTest }`
|
||||
- `ProjTest map[string]CtrTest`
|
||||
- `CtrTest map[string]ApiTestDef`
|
||||
- `ApiTestDef struct { Desc string; Func func(a *Api) }`
|
||||
|
||||
链式 API:
|
||||
|
||||
- `Api` -- 起点,提供 `JSON/Query/Form/File/WithSession` 数据设置和 `Get/Post/Put/Delete` 直接终端
|
||||
- `ApiCase` -- 链式构建器,支持叠加多种数据类型,终端操作 `Get/Post/Put/Delete(desc, status, msg...)`
|
||||
- `ApiResponse` -- 响应对象,提供 `GetStatus/GetResult/GetMsg/GetBody/Fail`
|
||||
- 每个终端方法内自动记录 `TestRecord` 到 `TestCollector`
|
||||
|
||||
### 6. 新增 [testing_swagger.go](d:/work/hotimev1.5/testing_swagger.go)
|
||||
|
||||
- `GenerateSwagger(title, version, outputDir)` -- 遍历 TestProj 生成合并的 OpenAPI 3.0 JSON + Swagger UI HTML
|
||||
- 利用覆盖率数据,未覆盖接口在文档中标注"暂无测试用例"
|
||||
|
||||
## 三、覆盖率追踪
|
||||
|
||||
集成在 `testing_helper.go` 中:
|
||||
|
||||
- `CoverageReport` / `MethodCoverage` / `TestRecord` / `TestCollector` 类型
|
||||
- `PrintCoverage()` -- 对比 `Proj`(所有路由)和 `ProjTest`(有测试的路由),输出覆盖率报告
|
||||
- 运行时追踪:每个 `Post/Get/Put/Delete` 终端方法自动收集 `TestRecord`(路径、用例名、通过/失败、耗时)
|
||||
- 最终输出:总接口数、已覆盖数、覆盖率百分比、每个接口的用例数和通过/失败计数
|
||||
|
||||
## 四、业务层接入(xbc 示例)
|
||||
|
||||
### 7. 修改 [xbc/app/user.go](d:/work/xbc/app/user.go) -- 末尾添加 `var UserTest = CtrTest{...}`
|
||||
|
||||
```go
|
||||
var UserTest = CtrTest{
|
||||
"login": {"用户登录", func(a *Api) {
|
||||
a.JSON(Map{"name": "138", "password": "123456"}).Post("正常登录", 0)
|
||||
// ...
|
||||
}},
|
||||
"create": {"用户注册", func(a *Api) {
|
||||
phone := "138" + ObjToStr(RandX(10000000, 99999999))
|
||||
// 不需要 defer 清理,事务自动回滚
|
||||
a.JSON(Map{"phone": phone, "password": "123456"}).Post("正常注册", 0)
|
||||
a.JSON(Map{"phone": phone, "password": "123456"}).Post("重复注册", 3, "该手机号已被注册")
|
||||
}},
|
||||
}
|
||||
```
|
||||
|
||||
### 8. 修改 [xbc/app/init.go](d:/work/xbc/app/init.go) -- 添加 `var ProjectTest = ProjTest{...}`
|
||||
|
||||
### 9. 新增 [xbc/app/app_test.go](d:/work/xbc/app/app_test.go)
|
||||
|
||||
```go
|
||||
func TestMain(m *testing.M) {
|
||||
testApp = NewTestApp("../config/config.json", TestProj{
|
||||
"app": {Project, ProjectTest},
|
||||
})
|
||||
code := m.Run()
|
||||
testApp.PrintCoverage()
|
||||
testApp.GenerateSwagger("小帮菜 API", "2.1.0", testApp.Config.GetString("tpt"))
|
||||
os.Exit(code)
|
||||
}
|
||||
|
||||
func TestApi(t *testing.T) {
|
||||
testApp.RunTests(t)
|
||||
}
|
||||
```
|
||||
|
||||
## 五、文件改动汇总
|
||||
|
||||
- **修改** `d:/work/hotimev1.5/db/db.go` -- 加 testTx 字段 + BeginTestTx/RollbackTestTx
|
||||
- **修改** `d:/work/hotimev1.5/db/query.go` -- Query/Exec 各加 testTx 判断
|
||||
- **修改** `d:/work/hotimev1.5/db/transaction.go` -- Action 加 SAVEPOINT 分支 + testTx 传递
|
||||
- **新增** `d:/work/hotimev1.5/testing_helper.go` -- TestApp、RunTests、覆盖率
|
||||
- **新增** `d:/work/hotimev1.5/testing_api.go` -- 链式 API + TestCollector
|
||||
- **新增** `d:/work/hotimev1.5/testing_swagger.go` -- Swagger 生成
|
||||
- **修改** `d:/work/xbc/app/user.go` -- 末尾添加 UserTest
|
||||
- **修改** `d:/work/xbc/app/init.go` -- 添加 ProjectTest
|
||||
- **新增** `d:/work/xbc/app/app_test.go` -- 测试入口
|
||||
|
||||
41
.cursor/plans/测试框架文档编写_3cbaf8c4.plan.md
Normal file
41
.cursor/plans/测试框架文档编写_3cbaf8c4.plan.md
Normal file
@ -0,0 +1,41 @@
|
||||
---
|
||||
name: 测试框架文档编写
|
||||
overview: 在 docs/ 目录新增 API 测试框架文档,并在 README.md 文档表格中增加入口链接,保持与现有文档风格一致。
|
||||
todos:
|
||||
- id: docs-testing
|
||||
content: 新增 docs/Testing_API测试框架.md 文档
|
||||
status: completed
|
||||
- id: readme-entry
|
||||
content: README.md 文档表格增加测试框架入口
|
||||
status: completed
|
||||
isProject: false
|
||||
---
|
||||
|
||||
# API 测试框架文档
|
||||
|
||||
## 改动文件
|
||||
|
||||
### 1. 新增 `docs/Testing_API测试框架.md`
|
||||
|
||||
参照现有文档风格(如 QUICKSTART.md、HoTimeDB_API参考.md),包含以下章节:
|
||||
|
||||
- 概述(事务回滚隔离 + 链式 API + 覆盖率 + Swagger)
|
||||
- 快速开始(3 步接入示例)
|
||||
- 核心类型(TestProj/TestProjDef/ProjTest/CtrTest/ApiTestDef)
|
||||
- 链式 API 参考(Api/ApiCase/ApiResponse 的所有方法)
|
||||
- 完整场景对照表(GET/POST/JSON/Form/File/混合/WithSession)
|
||||
- 事务回滚机制说明(testTx + SAVEPOINT 原理)
|
||||
- 覆盖率报告(PrintCoverage 输出示例)
|
||||
- Swagger 文档生成(GenerateSwagger 用法)
|
||||
- 指定运行范围(go test -run 用法)
|
||||
- 框架改动说明(db 层 3 文件改动概述)
|
||||
|
||||
### 2. 修改 [README.md](d:/work/hotimev1.5/README.md)
|
||||
|
||||
在文档表格(第 18-27 行)中增加一行:
|
||||
|
||||
```markdown
|
||||
| [API 测试框架](docs/Testing_API测试框架.md) | 接口测试、事务隔离、覆盖率追踪、Swagger 文档生成 |
|
||||
```
|
||||
|
||||
插入位置:在"改进规划"行之前,作为文档表格的倒数第二行。
|
||||
@ -1,4 +1,4 @@
|
||||
# HoTime
|
||||
# HoTime
|
||||
|
||||
**高性能 Go Web 服务框架**
|
||||
|
||||
@ -11,6 +11,7 @@
|
||||
- **三级缓存** - Memory > Redis > DB,自动穿透与回填
|
||||
- **Session 管理** - 内置会话管理,支持多种存储后端
|
||||
- **代码生成** - 根据数据库表自动生成 CRUD 接口
|
||||
- **API 测试** - 内置接口测试框架,链式 API、事务自动回滚、覆盖率报告、交互式调试控制台
|
||||
- **开箱即用** - 微信支付/公众号/小程序、阿里云、腾讯云等 SDK 内置
|
||||
|
||||
## 文档
|
||||
@ -24,6 +25,7 @@
|
||||
| [代码生成器](docs/CodeGen_使用说明.md) | 自动 CRUD 代码生成、配置规则 |
|
||||
| [数据库设计规范](docs/DatabaseDesign_数据库设计规范.md) | 表命名、字段命名、时间类型、必有字段规范 |
|
||||
| [代码生成配置规范](docs/CodeConfig_代码生成配置规范.md) | codeConfig、菜单权限、字段规则配置说明 |
|
||||
| [API 测试框架](docs/Testing_API测试框架.md) | 接口测试、事务隔离、覆盖率追踪、API 调试控制台生成 |
|
||||
| [改进规划](docs/ROADMAP_改进规划.md) | 待改进项、设计思考、版本迭代规划 |
|
||||
|
||||
## 安装
|
||||
@ -59,6 +61,7 @@ go get code.hoteas.com/golang/hotime
|
||||
| 内置缓存 | ✅ 三级缓存 | ❌ | ❌ | ❌ |
|
||||
| Session | ✅ 内置 | ❌ 需插件 | ❌ 需插件 | ❌ 需插件 |
|
||||
| 代码生成 | ✅ | ❌ | ❌ | ❌ |
|
||||
| API 测试框架 | ✅ 内置 | ❌ 需插件 | ❌ 需插件 | ❌ 需插件 |
|
||||
| 微信/支付集成 | ✅ 内置 | ❌ | ❌ | ❌ |
|
||||
|
||||
## 适用场景
|
||||
|
||||
@ -1,14 +1,8 @@
|
||||
package hotime
|
||||
|
||||
import (
|
||||
. "code.hoteas.com/golang/hotime/cache"
|
||||
"code.hoteas.com/golang/hotime/code"
|
||||
. "code.hoteas.com/golang/hotime/common"
|
||||
. "code.hoteas.com/golang/hotime/db"
|
||||
. "code.hoteas.com/golang/hotime/log"
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"github.com/sirupsen/logrus"
|
||||
"io/ioutil"
|
||||
"net/http"
|
||||
"net/url"
|
||||
@ -17,6 +11,13 @@ import (
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
. "code.hoteas.com/golang/hotime/cache"
|
||||
"code.hoteas.com/golang/hotime/code"
|
||||
. "code.hoteas.com/golang/hotime/common"
|
||||
. "code.hoteas.com/golang/hotime/db"
|
||||
. "code.hoteas.com/golang/hotime/log"
|
||||
"github.com/sirupsen/logrus"
|
||||
)
|
||||
|
||||
type Application struct {
|
||||
|
||||
21
db/db.go
21
db/db.go
@ -32,6 +32,7 @@ type HoTimeDB struct {
|
||||
mu sync.RWMutex
|
||||
limitMu sync.Mutex
|
||||
Dialect Dialect // 数据库方言适配器
|
||||
testTx *sql.Tx // 测试事务:设置后所有操作都在此事务内,测试结束回滚
|
||||
}
|
||||
|
||||
// SetConnect 设置数据库配置连接
|
||||
@ -145,3 +146,23 @@ func trimQuotes(s string) string {
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
// BeginTestTx 开启测试事务,设置后所有 Query/Exec/Action 都在此事务内执行
|
||||
func (that *HoTimeDB) BeginTestTx() error {
|
||||
tx, err := that.DB.Begin()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
that.testTx = tx
|
||||
return nil
|
||||
}
|
||||
|
||||
// RollbackTestTx 回滚测试事务,清除 testTx 状态
|
||||
func (that *HoTimeDB) RollbackTestTx() error {
|
||||
if that.testTx != nil {
|
||||
err := that.testTx.Rollback()
|
||||
that.testTx = nil
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
@ -61,7 +61,9 @@ func (that *HoTimeDB) queryWithRetry(query string, retried bool, args ...interfa
|
||||
// 处理参数中的 slice 类型
|
||||
processedArgs := that.processArgs(args)
|
||||
|
||||
if that.Tx != nil {
|
||||
if that.testTx != nil {
|
||||
resl, err = that.testTx.Query(query, processedArgs...)
|
||||
} else if that.Tx != nil {
|
||||
resl, err = that.Tx.Query(query, processedArgs...)
|
||||
} else {
|
||||
resl, err = db.Query(query, processedArgs...)
|
||||
@ -117,7 +119,9 @@ func (that *HoTimeDB) execWithRetry(query string, retried bool, args ...interfac
|
||||
// 处理参数中的 slice 类型
|
||||
processedArgs := that.processArgs(args)
|
||||
|
||||
if that.Tx != nil {
|
||||
if that.testTx != nil {
|
||||
resl, e = that.testTx.Exec(query, processedArgs...)
|
||||
} else if that.Tx != nil {
|
||||
resl, e = that.Tx.Exec(query, processedArgs...)
|
||||
} else {
|
||||
resl, e = that.DB.Exec(query, processedArgs...)
|
||||
|
||||
@ -1,11 +1,16 @@
|
||||
package db
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
)
|
||||
|
||||
var savepointCounter uint64
|
||||
|
||||
// Action 事务操作
|
||||
// 如果 action 返回 true 则提交事务;返回 false 则回滚
|
||||
// 测试模式下(testTx != nil),使用 SAVEPOINT 代替真事务,确保嵌套事务不会绕过外层测试事务
|
||||
func (that *HoTimeDB) Action(action func(db HoTimeDB) (isSuccess bool)) (isSuccess bool) {
|
||||
db := HoTimeDB{
|
||||
DB: that.DB,
|
||||
@ -26,6 +31,20 @@ func (that *HoTimeDB) Action(action func(db HoTimeDB) (isSuccess bool)) (isSucce
|
||||
Dialect: that.Dialect,
|
||||
mu: sync.RWMutex{},
|
||||
limitMu: sync.Mutex{},
|
||||
testTx: that.testTx,
|
||||
}
|
||||
|
||||
if that.testTx != nil {
|
||||
spName := fmt.Sprintf("sp_%d", atomic.AddUint64(&savepointCounter, 1))
|
||||
_, _ = that.testTx.Exec("SAVEPOINT " + spName)
|
||||
db.Tx = that.testTx
|
||||
isSuccess = action(db)
|
||||
if !isSuccess {
|
||||
_, _ = that.testTx.Exec("ROLLBACK TO SAVEPOINT " + spName)
|
||||
} else {
|
||||
_, _ = that.testTx.Exec("RELEASE SAVEPOINT " + spName)
|
||||
}
|
||||
return isSuccess
|
||||
}
|
||||
|
||||
tx, err := db.Begin()
|
||||
|
||||
515
docs/Testing_API测试框架.md
Normal file
515
docs/Testing_API测试框架.md
Normal file
@ -0,0 +1,515 @@
|
||||
# HoTime API 测试框架使用说明
|
||||
|
||||
不启动 HTTP 服务、自动事务回滚、链式 API、覆盖率报告、API 调试控制台生成。
|
||||
|
||||
## 目录
|
||||
|
||||
- [概述](#概述)
|
||||
- [快速开始](#快速开始)
|
||||
- [核心类型](#核心类型)
|
||||
- [链式 API 参考](#链式-api-参考)
|
||||
- [完整场景对照](#完整场景对照)
|
||||
- [事务隔离机制](#事务隔离机制)
|
||||
- [覆盖率报告](#覆盖率报告)
|
||||
- [API 调试控制台](#api-调试控制台)
|
||||
- [指定运行范围](#指定运行范围)
|
||||
- [框架改动说明](#框架改动说明)
|
||||
|
||||
---
|
||||
|
||||
## 概述
|
||||
|
||||
HoTime 内置 API 测试框架,核心能力:
|
||||
|
||||
| 能力 | 说明 |
|
||||
|------|------|
|
||||
| **零启动** | 基于 `net/http/httptest`,不需要启动 HTTP 服务器 |
|
||||
| **事务回滚** | 每个接口方法独立开启数据库事务,测试结束自动回滚,数据库不留任何痕迹 |
|
||||
| **SAVEPOINT** | 业务代码中的 `that.Db.Action()` 在测试模式下自动用 SAVEPOINT 替代真事务,嵌套事务完整可用 |
|
||||
| **链式 API** | `a.JSON(...).Post("描述", 期望status)` 一行完成:数据组装 + 请求 + 断言 |
|
||||
| **覆盖率** | 自动对比路由注册表与测试定义,输出哪些接口已覆盖、哪些未覆盖 |
|
||||
| **调试控制台** | 根据测试用例自动生成交互式 API 调试控制台,支持在线测试、认证管理、参数编辑 |
|
||||
|
||||
---
|
||||
|
||||
## 快速开始
|
||||
|
||||
### 第一步:创建测试定义文件(推荐)
|
||||
|
||||
**推荐方式**:为每个 handler 创建对应的 `_test.go` 文件,测试定义与业务代码分离。`_test.go` 文件仅在 `go test` 时编译,不影响生产构建。
|
||||
|
||||
```go
|
||||
// app/user.go — 只有业务代码
|
||||
package app
|
||||
|
||||
import . "code.hoteas.com/golang/hotime"
|
||||
import . "code.hoteas.com/golang/hotime/common"
|
||||
|
||||
var UserCtr = Ctr{
|
||||
"login": func(that *Context) { /* ... */ },
|
||||
"info": func(that *Context) { /* ... */ },
|
||||
"create": func(that *Context) { /* ... */ },
|
||||
}
|
||||
```
|
||||
|
||||
```go
|
||||
// app/user_test.go — 测试定义
|
||||
package app
|
||||
|
||||
import . "code.hoteas.com/golang/hotime"
|
||||
import . "code.hoteas.com/golang/hotime/common"
|
||||
|
||||
var UserTest = CtrTest{
|
||||
"login": {Desc: "用户登录", Func: func(a *Api) {
|
||||
a.JSON(Map{"name": "13800138000", "password": "123456"}).Post("正常登录", 0)
|
||||
a.JSON(Map{"name": "13800138000", "password": ""}).Post("密码为空", 4, "用户名或密码不能为空")
|
||||
a.JSON(Map{"name": "1380013", "password": "123456"}).Post("手机号格式错误", 4, "手机号码格式错误")
|
||||
}},
|
||||
"create": {Desc: "用户注册", Func: func(a *Api) {
|
||||
phone := "138" + ObjToStr(RandX(10000000, 99999999))
|
||||
a.JSON(Map{"phone": phone, "password": "123456"}).Post("正常注册", 0)
|
||||
a.JSON(Map{"phone": phone, "password": "123456"}).Post("重复注册", 3, "该手机号已被注册")
|
||||
}},
|
||||
}
|
||||
```
|
||||
|
||||
> **为什么要分离?** `_test.go` 文件仅在 `go test` 时编译,不会进入生产二进制,保持业务代码干净。同时符合 Go 语言惯例:测试相关代码放在 `_test.go` 文件中。
|
||||
|
||||
### 第二步:注册路由和测试
|
||||
|
||||
路由在 `init.go` 中注册,测试注册放在 `app_test.go` 中(因为 `_test.go` 中的变量只在测试编译时可见):
|
||||
|
||||
```go
|
||||
// app/init.go — 只注册路由
|
||||
var Project = Proj{
|
||||
"user": UserCtr,
|
||||
// ...
|
||||
}
|
||||
```
|
||||
|
||||
```go
|
||||
// app/app_test.go — 注册测试 + 运行入口
|
||||
package app
|
||||
|
||||
import (
|
||||
"os"
|
||||
"testing"
|
||||
. "code.hoteas.com/golang/hotime"
|
||||
)
|
||||
|
||||
var ProjectTest = ProjTest{
|
||||
"user": UserTest,
|
||||
}
|
||||
|
||||
var testApp *TestApp
|
||||
|
||||
func TestMain(m *testing.M) {
|
||||
os.Chdir("..") // 确保工作目录为项目根目录
|
||||
testApp = NewTestApp("config/config.json", TestProj{
|
||||
"app": {Proj: Project, Tests: ProjectTest},
|
||||
})
|
||||
code := m.Run()
|
||||
testApp.PrintCoverage()
|
||||
testApp.GenerateSwagger("My API", "1.0.0", testApp.Config.GetString("tpt"))
|
||||
os.Exit(code)
|
||||
}
|
||||
|
||||
func TestApi(t *testing.T) {
|
||||
testApp.RunTests(t)
|
||||
}
|
||||
```
|
||||
|
||||
### 推荐目录结构
|
||||
|
||||
```
|
||||
app/
|
||||
├── user.go ← 业务代码 (UserCtr)
|
||||
├── user_test.go ← 测试定义 (UserTest)
|
||||
├── tags.go ← 业务代码 (TagsCtr)
|
||||
├── tags_test.go ← 测试定义 (TagsTest)
|
||||
├── init.go ← 路由注册 (Project)
|
||||
└── app_test.go ← 测试注册 (ProjectTest) + TestMain + TestApi
|
||||
```
|
||||
|
||||
### 运行测试
|
||||
|
||||
```bash
|
||||
go test ./app/... -v
|
||||
```
|
||||
|
||||
> **注意**:`TestMain` 中的 `os.Chdir("..")` 确保 `go test ./app/` 的工作目录为项目根目录,使配置文件路径和模板输出路径正确。
|
||||
|
||||
---
|
||||
|
||||
## 核心类型
|
||||
|
||||
### 注册类型
|
||||
|
||||
```go
|
||||
// 顶层:多项目集合(项目名 → 定义)
|
||||
type TestProj map[string]TestProjDef
|
||||
|
||||
// 单项目:路由 + 测试绑定在一起
|
||||
type TestProjDef struct {
|
||||
Proj Proj // 路由定义(与 Run 传入的 Router 保持一致)
|
||||
Tests ProjTest // 测试定义
|
||||
}
|
||||
|
||||
// 控制器级测试(控制器名 → 控制器测试集合)
|
||||
type ProjTest map[string]CtrTest
|
||||
|
||||
// 方法级测试(方法名 → 单条用例定义)
|
||||
type CtrTest map[string]ApiTestDef
|
||||
|
||||
// 单个接口的用例定义
|
||||
type ApiTestDef struct {
|
||||
Desc string // 接口描述,用于 Swagger 说明和 t.Run 层级名称
|
||||
Func func(a *Api) // 测试函数体
|
||||
}
|
||||
```
|
||||
|
||||
### TestApp
|
||||
|
||||
```go
|
||||
// NewTestApp 创建测试应用
|
||||
// configPath: 配置文件路径
|
||||
// projects: TestProj 注册所有项目的路由和测试
|
||||
// listeners: 可选,与 SetConnectListener 相同的请求拦截器
|
||||
func NewTestApp(configPath string, projects TestProj, listeners ...func(*Context) bool) *TestApp
|
||||
|
||||
// RunTests 运行所有注册的测试(每个方法级别自动事务隔离)
|
||||
func (app *TestApp) RunTests(t *testing.T)
|
||||
|
||||
// PrintCoverage 输出覆盖率报告,返回报告结构体
|
||||
func (app *TestApp) PrintCoverage() CoverageReport
|
||||
|
||||
// GenerateSwagger 生成 API 调试控制台(api-spec.json + index.html)
|
||||
func (app *TestApp) GenerateSwagger(title, version, outputDir string) error
|
||||
|
||||
// DB 获取测试应用的数据库实例(在事务内)
|
||||
func (app *TestApp) DB() *HoTimeDB
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 链式 API 参考
|
||||
|
||||
测试函数接收 `*Api` 作为入口,调用数据设置方法返回 `*ApiCase`,再调用终端方法发出请求并断言。
|
||||
|
||||
### Api — 测试入口
|
||||
|
||||
| 方法 | 说明 | 返回 |
|
||||
|------|------|------|
|
||||
| `a.JSON(body)` | 设置 JSON body | `*ApiCase` |
|
||||
| `a.Query(params)` | 设置 URL 查询参数 | `*ApiCase` |
|
||||
| `a.Form(body)` | 设置 form 表单 body | `*ApiCase` |
|
||||
| `a.File(field, name, content)` | 设置上传文件 | `*ApiCase` |
|
||||
| `a.WithSession(s)` | 返回携带 session 的新 Api | `*Api` |
|
||||
| `a.Get(desc, status, msg...)` | 直接 GET 请求(无数据场景) | `*ApiResponse` |
|
||||
| `a.Post(desc, status, msg...)` | 直接 POST 请求(无数据场景) | `*ApiResponse` |
|
||||
| `a.Put(desc, status, msg...)` | 直接 PUT 请求 | `*ApiResponse` |
|
||||
| `a.Delete(desc, status, msg...)` | 直接 DELETE 请求 | `*ApiResponse` |
|
||||
| `a.DB()` | 获取数据库实例(在事务内,可用于准备/清查数据) | `*HoTimeDB` |
|
||||
|
||||
### ApiCase — 链式构建器
|
||||
|
||||
`Api` 的数据设置方法返回 `*ApiCase`,支持继续叠加数据:
|
||||
|
||||
| 方法 | 说明 | 返回 |
|
||||
|------|------|------|
|
||||
| `c.JSON(body)` | 追加 JSON body | `*ApiCase` |
|
||||
| `c.Query(params)` | 追加 URL 查询参数 | `*ApiCase` |
|
||||
| `c.Form(body)` | 追加 form 字段 | `*ApiCase` |
|
||||
| `c.File(field, name, content)` | 设置上传文件 | `*ApiCase` |
|
||||
| `c.WithSession(s)` | 覆盖本次请求的 session | `*ApiCase` |
|
||||
| `c.Get(desc, status, msg...)` | 终端:发送 GET 请求并断言 | `*ApiResponse` |
|
||||
| `c.Post(desc, status, msg...)` | 终端:发送 POST 请求并断言 | `*ApiResponse` |
|
||||
| `c.Put(desc, status, msg...)` | 终端:发送 PUT 请求并断言 | `*ApiResponse` |
|
||||
| `c.Delete(desc, status, msg...)` | 终端:发送 DELETE 请求并断言 | `*ApiResponse` |
|
||||
|
||||
**终端方法参数说明:**
|
||||
|
||||
| 参数 | 类型 | 说明 |
|
||||
|------|------|------|
|
||||
| `desc` | string | 用例描述,作为 `t.Run` 的子测试名称 |
|
||||
| `status` | int | 期望的 JSON 响应体中 `status` 字段值(0=成功) |
|
||||
| `msg` | ...string | 可选,期望的 `msg` 字段值(传空则不校验) |
|
||||
|
||||
### ApiResponse — 响应对象
|
||||
|
||||
终端方法返回 `*ApiResponse`,可用于进一步的自定义断言:
|
||||
|
||||
```go
|
||||
resp := a.JSON(Map{"name": "user", "password": "123"}).Post("正常登录", 0)
|
||||
|
||||
// 获取响应字段
|
||||
resp.GetStatus() // int:业务 status
|
||||
resp.GetMsg() // string:业务 msg
|
||||
resp.GetResult() // interface{}:result 字段
|
||||
resp.GetBody() // Map:完整响应体
|
||||
|
||||
// 自定义断言
|
||||
token := resp.GetBody().GetMap("result").GetString("token")
|
||||
if token == "" {
|
||||
resp.Fail("缺少 token 字段")
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 完整场景对照
|
||||
|
||||
以下示例均写在 `_test.go` 文件的 `CtrTest` 定义中(如 `user_test.go`),`a` 为 `*Api` 参数:
|
||||
|
||||
```go
|
||||
// POST JSON(最常见)
|
||||
a.JSON(Map{"name": "138", "password": "123"}).Post("正常登录", 0)
|
||||
|
||||
// GET 无参数
|
||||
a.Get("未登录", 2, "请先登录")
|
||||
|
||||
// GET + URL 参数
|
||||
a.Query(Map{"shop_id": "1", "page": "1"}).Get("查询列表", 0)
|
||||
|
||||
// 需登录 + GET
|
||||
a.WithSession(Map{"user_id": int64(1)}).Get("已登录", 0)
|
||||
|
||||
// 需登录 + GET + URL 参数
|
||||
a.WithSession(Map{"user_id": int64(1)}).Query(Map{"shop_id": "1"}).Get("带参查询", 0)
|
||||
|
||||
// 需登录 + POST JSON
|
||||
a.WithSession(Map{"user_id": int64(1)}).JSON(Map{"name": "新名字"}).Post("更新信息", 0)
|
||||
|
||||
// 混合:URL 参数 + JSON body
|
||||
a.Query(Map{"token": "abc"}).JSON(Map{"phone": "138"}).Post("混合传参", 3, "异常")
|
||||
|
||||
// POST Form 表单
|
||||
a.Form(Map{"shop_id": "1", "id": "5"}).Post("表单提交", 0)
|
||||
|
||||
// 文件上传
|
||||
a.File("file", "photo.png", imgBytes).Post("上传文件", 0)
|
||||
|
||||
// 文件 + 表单字段混合
|
||||
a.File("file", "photo.png", imgBytes).Form(Map{"type": "avatar"}).Post("上传头像", 0)
|
||||
|
||||
// PUT
|
||||
a.JSON(Map{"name": "新名字"}).Put("更新", 0)
|
||||
|
||||
// DELETE + URL 参数
|
||||
a.Query(Map{"id": "5"}).Delete("删除", 0)
|
||||
|
||||
// 校验响应中的具体字段
|
||||
resp := a.JSON(Map{"name": "138", "password": "123"}).Post("正常登录", 0)
|
||||
if resp.GetBody().GetMap("result").GetString("token") == "" {
|
||||
resp.Fail("响应中缺少 token 字段")
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 事务隔离机制
|
||||
|
||||
### 工作原理
|
||||
|
||||
`RunTests` 在每个**方法级**测试前开启一个数据库事务(`testTx`),测试结束后无论成功与否一律回滚:
|
||||
|
||||
```
|
||||
TestApi
|
||||
└── app(项目名)
|
||||
└── user(控制器名)
|
||||
└── create(方法名)← 在这一级 BEGIN → 运行测试用例 → ROLLBACK
|
||||
└── 正常注册(用例)
|
||||
└── 重复注册(用例)
|
||||
```
|
||||
|
||||
所有测试用例共享同一个 `testTx`,因此"正常注册"写入的数据对"重复注册"可见,模拟了真实的并发隔离场景。
|
||||
|
||||
### 嵌套事务 (SAVEPOINT)
|
||||
|
||||
业务代码中常见的 `that.Db.Action()` 在测试模式下自动切换为 SAVEPOINT 机制,不会脱离外层测试事务:
|
||||
|
||||
```go
|
||||
// 业务代码(无需修改)
|
||||
that.Db.Action(func(db HoTimeDB) bool {
|
||||
db.Insert("order", orderData) // 在 SAVEPOINT sp_N 内执行
|
||||
db.Update("customer", balanceData, where)
|
||||
return true // → RELEASE SAVEPOINT sp_N(提交到外层 testTx)
|
||||
// 返回 false → ROLLBACK TO SAVEPOINT sp_N(只回滚这一层)
|
||||
})
|
||||
// 测试结束 → ROLLBACK testTx(回滚一切)
|
||||
```
|
||||
|
||||
| 机制 | 生产模式 | 测试模式 |
|
||||
|------|----------|----------|
|
||||
| `Action()` | `BEGIN` / `COMMIT` / `ROLLBACK` | `SAVEPOINT` / `RELEASE` / `ROLLBACK TO` |
|
||||
| 数据持久化 | 持久化到数据库 | 测试结束全部回滚 |
|
||||
| 对现有代码影响 | 无 | 无(`testTx` 默认 nil) |
|
||||
|
||||
### 一次性数据的处理
|
||||
|
||||
对于需要随机数据的测试(如注册),直接用 `RandX` 生成随机值即可,**无需手动清理**:
|
||||
|
||||
```go
|
||||
// app/user_test.go 中的 UserTest 片段
|
||||
"create": {Desc: "用户注册", Func: func(a *Api) {
|
||||
// 生成随机手机号,测试完成后事务自动回滚,数据库中不留任何记录
|
||||
phone := "138" + ObjToStr(RandX(10000000, 99999999))
|
||||
a.JSON(Map{"phone": phone, "password": "123456"}).Post("正常注册", 0)
|
||||
a.JSON(Map{"phone": phone, "password": "123456"}).Post("重复注册", 3, "该手机号已被注册")
|
||||
}},
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 覆盖率报告
|
||||
|
||||
`PrintCoverage()` 自动对比 `Proj`(所有路由)和 `ProjTest`(有测试的路由),在 `TestMain` 中调用:
|
||||
|
||||
```go
|
||||
func TestMain(m *testing.M) {
|
||||
// ...
|
||||
code := m.Run()
|
||||
testApp.PrintCoverage() // 在测试运行完成后输出报告
|
||||
os.Exit(code)
|
||||
}
|
||||
```
|
||||
|
||||
**输出示例:**
|
||||
|
||||
```
|
||||
========== API 测试覆盖率报告 ==========
|
||||
总接口: 42 | 已覆盖: 6 | 覆盖率: 14.3%
|
||||
|
||||
已覆盖的接口:
|
||||
+ /app/user/login 4 用例 (4 通过, 0 失败) 12ms
|
||||
+ /app/user/info 2 用例 (2 通过, 0 失败) 5ms
|
||||
+ /app/user/token 2 用例 (2 通过, 0 失败) 4ms
|
||||
+ /app/user/create 4 用例 (4 通过, 0 失败) 18ms
|
||||
+ /app/user/forget 2 用例 (2 通过, 0 失败) 6ms
|
||||
+ /app/user/file 2 用例 (2 通过, 0 失败) 9ms
|
||||
|
||||
未覆盖的接口:
|
||||
- app/goods/list
|
||||
- app/goods/create
|
||||
- app/order/list
|
||||
- ... (共36个未覆盖)
|
||||
========================================
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## API 调试控制台
|
||||
|
||||
`GenerateSwagger` 遍历 `TestProj` 中所有项目的路由和测试用例,生成一个**交互式 API 调试控制台**(暗色高对比度主题),支持在线调试、参数编辑、认证管理。
|
||||
|
||||
```go
|
||||
testApp.GenerateSwagger(
|
||||
"My API", // 文档标题
|
||||
"2.1.0", // 版本号
|
||||
testApp.Config.GetString("tpt"), // 输出目录(如 "tpt")
|
||||
)
|
||||
```
|
||||
|
||||
生成文件:
|
||||
|
||||
```
|
||||
{outputDir}/swagger/
|
||||
├── api-spec.json ← 自定义 API 规范文件(包含路由、测试用例、请求/响应数据)
|
||||
└── index.html ← 交互式调试控制台
|
||||
```
|
||||
|
||||
访问地址:`http://localhost:8081/swagger/`
|
||||
|
||||
### 控制台功能
|
||||
|
||||
| 功能 | 说明 |
|
||||
|------|------|
|
||||
| **三级导航** | 左侧侧边栏按 项目 > 控制器 > 方法 三级树形展开 |
|
||||
| **搜索和筛选** | 支持关键字搜索,按 全部/已测试/未测试 筛选 |
|
||||
| **测试用例查看** | 每个接口的测试用例以手风琴形式展示(第一个默认展开),包含请求头、请求参数(Query/JSON/Form/文件)、响应结果 |
|
||||
| **接口调试** | 可编辑的请求头、Query 参数、文件上传、请求体(表单/JSON 互斥切换),直接发送请求 |
|
||||
| **预填用例** | 下拉选择测试用例一键填充请求参数,方便快速调试 |
|
||||
| **全局认证** | 顶部认证栏支持四种模式:无认证、Header (Authorization)、URL (?token=)、Cookie,修改后实时同步到各参数区域 |
|
||||
| **Cookie 隔离** | Header/URL 认证模式下自动阻止浏览器发送 Cookie(`credentials: 'omit'`),避免意外自动授权 |
|
||||
| **cURL 生成** | 每次请求自动生成对应的 cURL 命令,支持一键复制 |
|
||||
| **响应格式化** | JSON 响应自动格式化显示,支持复制 |
|
||||
| **覆盖率可视化** | 已测试接口显示通过/失败计数徽章,未测试接口标记"未测试" |
|
||||
|
||||
### 多项目支持
|
||||
|
||||
所有注册项目的路由和测试用例合并到同一份 `api-spec.json`,在侧边栏按项目名分组,不会互相覆盖:
|
||||
|
||||
```
|
||||
侧边栏结构:
|
||||
├── app(项目)
|
||||
│ ├── user(控制器)
|
||||
│ │ ├── POST login 用户登录 ✅ 4/4
|
||||
│ │ ├── POST info 未测试
|
||||
│ │ └── POST create 用户注册 ✅ 4/4
|
||||
│ └── goods(控制器)
|
||||
│ └── ...
|
||||
└── customer(项目)
|
||||
└── pay(控制器)
|
||||
└── ...
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 指定运行范围
|
||||
|
||||
`RunTests` 使用 Go 标准 `t.Run()` 子测试机制,天然支持 `-run` 参数过滤:
|
||||
|
||||
```bash
|
||||
# 运行全部测试
|
||||
go test ./app/... -v
|
||||
|
||||
# 只跑 user 控制器的所有接口
|
||||
go test ./app/... -v -run TestApi/app/user
|
||||
|
||||
# 只跑 user/login 接口
|
||||
go test ./app/... -v -run TestApi/app/user/login
|
||||
|
||||
# 只跑 login 接口中"密码为空"这一个用例
|
||||
go test ./app/... -v -run "TestApi/app/user/login/密码为空"
|
||||
|
||||
# 同时跑 user 和 goods 两个控制器
|
||||
go test ./app/... -v -run "TestApi/app/(user|goods)"
|
||||
|
||||
# 跑所有控制器中包含"未登录"的用例
|
||||
go test ./app/... -v -run "TestApi/.*/.*未登录"
|
||||
```
|
||||
|
||||
子测试层级结构:
|
||||
|
||||
```
|
||||
TestApi
|
||||
└── {项目名} ← -run TestApi/app
|
||||
└── {控制器名} ← -run TestApi/app/user
|
||||
└── {方法名} ← -run TestApi/app/user/login
|
||||
└── {ApiTestDef.Desc}(接口描述)
|
||||
└── {用例描述} ← -run TestApi/app/user/login/密码为空
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 框架改动说明
|
||||
|
||||
测试框架对 `hotimev1.5` 共改动 6 个文件,其中 3 个为修改、3 个为新增:
|
||||
|
||||
### 修改的文件
|
||||
|
||||
| 文件 | 改动 | 生产影响 |
|
||||
|------|------|----------|
|
||||
| `db/db.go` | 新增 `testTx *sql.Tx` 字段 + `BeginTestTx()`/`RollbackTestTx()` 方法 | 无(`testTx` 默认 nil) |
|
||||
| `db/query.go` | `Query` 和 `Exec` 各增加一个 `if testTx != nil` 分支 | 无(分支默认跳过) |
|
||||
| `db/transaction.go` | `Action()` 增加 SAVEPOINT 分支 | 无(`testTx` 为 nil 时走原有逻辑) |
|
||||
|
||||
### 新增的文件
|
||||
|
||||
| 文件 | 内容 |
|
||||
|------|------|
|
||||
| `testing_helper.go` | `TestApp`、`NewTestApp`、`SetupForTest`、`RunTests`(事务隔离)、`PrintCoverage`(覆盖率) |
|
||||
| `testing_api.go` | 注册类型 + `Api`/`ApiCase`/`ApiResponse` 链式 API + `TestCollector` |
|
||||
| `testing_swagger.go` | `GenerateSwagger`:合并所有项目生成 API 调试控制台(`api-spec.json` + `index.html`) |
|
||||
|
||||
> 所有新增文件仅在 `go test` 环境下被使用,生产构建不引入 `testing` 包依赖,完全不影响线上服务。
|
||||
377
testing_api.go
Normal file
377
testing_api.go
Normal file
@ -0,0 +1,377 @@
|
||||
package hotime
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"mime/multipart"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
. "code.hoteas.com/golang/hotime/common"
|
||||
. "code.hoteas.com/golang/hotime/db"
|
||||
)
|
||||
|
||||
// === 注册类型 ===
|
||||
|
||||
// TestProj 测试项目集合(项目名 → 定义)
|
||||
type TestProj map[string]TestProjDef
|
||||
|
||||
// TestProjDef 单个项目的路由 + 测试定义
|
||||
type TestProjDef struct {
|
||||
Proj Proj
|
||||
Tests ProjTest
|
||||
}
|
||||
|
||||
// ProjTest 项目级测试(控制器名 → 控制器测试)
|
||||
type ProjTest map[string]CtrTest
|
||||
|
||||
// CtrTest 控制器级测试(方法名 → 用例定义)
|
||||
type CtrTest map[string]ApiTestDef
|
||||
|
||||
// ApiTestDef 单个接口的测试定义
|
||||
type ApiTestDef struct {
|
||||
Desc string
|
||||
Func func(a *Api)
|
||||
}
|
||||
|
||||
// === Api:测试起点 ===
|
||||
|
||||
// Api 测试 API 入口,由 RunTests 自动创建并注入
|
||||
type Api struct {
|
||||
app *TestApp
|
||||
path string
|
||||
t *testing.T
|
||||
session Map
|
||||
}
|
||||
|
||||
// WithSession 返回一个携带 session 的新 Api 实例
|
||||
func (a *Api) WithSession(s Map) *Api {
|
||||
return &Api{
|
||||
app: a.app,
|
||||
path: a.path,
|
||||
t: a.t,
|
||||
session: s,
|
||||
}
|
||||
}
|
||||
|
||||
// JSON 设置 JSON body,返回 ApiCase 构建器
|
||||
func (a *Api) JSON(body interface{}) *ApiCase {
|
||||
c := a.newCase()
|
||||
c.jsonBody = body
|
||||
return c
|
||||
}
|
||||
|
||||
// Query 设置 URL 查询参数,返回 ApiCase 构建器
|
||||
func (a *Api) Query(params Map) *ApiCase {
|
||||
c := a.newCase()
|
||||
c.query = params
|
||||
return c
|
||||
}
|
||||
|
||||
// Form 设置表单 body,返回 ApiCase 构建器
|
||||
func (a *Api) Form(body Map) *ApiCase {
|
||||
c := a.newCase()
|
||||
c.formBody = body
|
||||
return c
|
||||
}
|
||||
|
||||
// File 设置上传文件,返回 ApiCase 构建器
|
||||
func (a *Api) File(field, name string, content []byte) *ApiCase {
|
||||
c := a.newCase()
|
||||
c.fileField = field
|
||||
c.fileName = name
|
||||
c.fileContent = content
|
||||
return c
|
||||
}
|
||||
|
||||
// Get 直接发送 GET 请求(无数据场景)
|
||||
func (a *Api) Get(desc string, status int, msg ...string) *ApiResponse {
|
||||
return a.newCase().Get(desc, status, msg...)
|
||||
}
|
||||
|
||||
// Post 直接发送 POST 请求(无数据场景)
|
||||
func (a *Api) Post(desc string, status int, msg ...string) *ApiResponse {
|
||||
return a.newCase().Post(desc, status, msg...)
|
||||
}
|
||||
|
||||
// Put 直接发送 PUT 请求(无数据场景)
|
||||
func (a *Api) Put(desc string, status int, msg ...string) *ApiResponse {
|
||||
return a.newCase().Put(desc, status, msg...)
|
||||
}
|
||||
|
||||
// Delete 直接发送 DELETE 请求(无数据场景)
|
||||
func (a *Api) Delete(desc string, status int, msg ...string) *ApiResponse {
|
||||
return a.newCase().Delete(desc, status, msg...)
|
||||
}
|
||||
|
||||
// DB 获取数据库实例(在测试事务内)
|
||||
func (a *Api) DB() *HoTimeDB {
|
||||
return &a.app.Db
|
||||
}
|
||||
|
||||
func (a *Api) newCase() *ApiCase {
|
||||
return &ApiCase{
|
||||
api: a,
|
||||
session: a.session,
|
||||
}
|
||||
}
|
||||
|
||||
// === ApiCase:链式构建器 ===
|
||||
|
||||
// ApiCase 测试用例构建器,支持链式调用叠加请求数据
|
||||
type ApiCase struct {
|
||||
api *Api
|
||||
session Map
|
||||
query Map
|
||||
jsonBody interface{}
|
||||
formBody Map
|
||||
fileField string
|
||||
fileName string
|
||||
fileContent []byte
|
||||
}
|
||||
|
||||
// JSON 叠加 JSON body
|
||||
func (c *ApiCase) JSON(body interface{}) *ApiCase {
|
||||
c.jsonBody = body
|
||||
return c
|
||||
}
|
||||
|
||||
// Query 叠加 URL 查询参数
|
||||
func (c *ApiCase) Query(params Map) *ApiCase {
|
||||
if c.query == nil {
|
||||
c.query = params
|
||||
} else {
|
||||
for k, v := range params {
|
||||
c.query[k] = v
|
||||
}
|
||||
}
|
||||
return c
|
||||
}
|
||||
|
||||
// Form 叠加表单 body
|
||||
func (c *ApiCase) Form(body Map) *ApiCase {
|
||||
if c.formBody == nil {
|
||||
c.formBody = body
|
||||
} else {
|
||||
for k, v := range body {
|
||||
c.formBody[k] = v
|
||||
}
|
||||
}
|
||||
return c
|
||||
}
|
||||
|
||||
// File 设置上传文件
|
||||
func (c *ApiCase) File(field, name string, content []byte) *ApiCase {
|
||||
c.fileField = field
|
||||
c.fileName = name
|
||||
c.fileContent = content
|
||||
return c
|
||||
}
|
||||
|
||||
// WithSession 覆盖本次请求的 session
|
||||
func (c *ApiCase) WithSession(s Map) *ApiCase {
|
||||
c.session = s
|
||||
return c
|
||||
}
|
||||
|
||||
// Get 终端操作:GET 请求 + 断言
|
||||
func (c *ApiCase) Get(desc string, status int, msg ...string) *ApiResponse {
|
||||
return c.execute("GET", desc, status, msg...)
|
||||
}
|
||||
|
||||
// Post 终端操作:POST 请求 + 断言
|
||||
func (c *ApiCase) Post(desc string, status int, msg ...string) *ApiResponse {
|
||||
return c.execute("POST", desc, status, msg...)
|
||||
}
|
||||
|
||||
// Put 终端操作:PUT 请求 + 断言
|
||||
func (c *ApiCase) Put(desc string, status int, msg ...string) *ApiResponse {
|
||||
return c.execute("PUT", desc, status, msg...)
|
||||
}
|
||||
|
||||
// Delete 终端操作:DELETE 请求 + 断言
|
||||
func (c *ApiCase) Delete(desc string, status int, msg ...string) *ApiResponse {
|
||||
return c.execute("DELETE", desc, status, msg...)
|
||||
}
|
||||
|
||||
func (c *ApiCase) execute(method, desc string, expectStatus int, expectMsg ...string) *ApiResponse {
|
||||
t := c.api.t
|
||||
var resp *ApiResponse
|
||||
|
||||
t.Run(desc, func(t *testing.T) {
|
||||
start := time.Now()
|
||||
|
||||
req := c.buildRequest(method)
|
||||
w := httptest.NewRecorder()
|
||||
c.api.app.Application.ServeHTTP(w, req)
|
||||
|
||||
duration := time.Since(start)
|
||||
|
||||
body := Map{}
|
||||
if len(w.Body.Bytes()) > 0 {
|
||||
_ = json.Unmarshal(w.Body.Bytes(), &body)
|
||||
}
|
||||
|
||||
resp = &ApiResponse{
|
||||
StatusCode: w.Code,
|
||||
Body: body,
|
||||
RawBody: w.Body.Bytes(),
|
||||
t: t,
|
||||
}
|
||||
|
||||
passed := true
|
||||
actualStatus := body.GetInt("status")
|
||||
if actualStatus != expectStatus {
|
||||
t.Errorf("状态码不匹配: 期望 %d, 实际 %d\n响应: %s", expectStatus, actualStatus, w.Body.String())
|
||||
passed = false
|
||||
}
|
||||
|
||||
if passed && len(expectMsg) > 0 && expectMsg[0] != "" {
|
||||
actualMsg := resp.GetMsg()
|
||||
if actualMsg != expectMsg[0] {
|
||||
t.Errorf("消息不匹配: 期望 %q, 实际 %q\n响应: %s", expectMsg[0], actualMsg, w.Body.String())
|
||||
passed = false
|
||||
}
|
||||
}
|
||||
|
||||
record := TestRecord{
|
||||
Path: c.api.path,
|
||||
Desc: desc,
|
||||
CaseName: desc,
|
||||
Passed: passed,
|
||||
Duration: duration,
|
||||
Method: method,
|
||||
}
|
||||
if c.query != nil {
|
||||
record.Query = c.query
|
||||
}
|
||||
if c.jsonBody != nil {
|
||||
record.JsonBody = c.jsonBody
|
||||
}
|
||||
if c.formBody != nil {
|
||||
record.FormBody = c.formBody
|
||||
}
|
||||
if c.fileContent != nil {
|
||||
record.HasFile = true
|
||||
record.FileField = c.fileField
|
||||
}
|
||||
if len(body) > 0 {
|
||||
record.ResponseBody = body
|
||||
}
|
||||
c.api.app.collector.Add(record)
|
||||
})
|
||||
|
||||
if resp == nil {
|
||||
resp = &ApiResponse{t: t}
|
||||
}
|
||||
return resp
|
||||
}
|
||||
|
||||
func (c *ApiCase) buildRequest(method string) *http.Request {
|
||||
path := c.api.path
|
||||
|
||||
if c.query != nil {
|
||||
q := url.Values{}
|
||||
for k, v := range c.query {
|
||||
q.Set(k, ObjToStr(v))
|
||||
}
|
||||
path += "?" + q.Encode()
|
||||
}
|
||||
|
||||
var body io.Reader
|
||||
var contentType string
|
||||
|
||||
switch {
|
||||
case c.fileContent != nil:
|
||||
buf := &bytes.Buffer{}
|
||||
writer := multipart.NewWriter(buf)
|
||||
part, _ := writer.CreateFormFile(c.fileField, c.fileName)
|
||||
_, _ = part.Write(c.fileContent)
|
||||
if c.formBody != nil {
|
||||
for k, v := range c.formBody {
|
||||
_ = writer.WriteField(k, ObjToStr(v))
|
||||
}
|
||||
}
|
||||
_ = writer.Close()
|
||||
body = buf
|
||||
contentType = writer.FormDataContentType()
|
||||
|
||||
case c.jsonBody != nil:
|
||||
jsonBytes, _ := json.Marshal(c.jsonBody)
|
||||
body = bytes.NewReader(jsonBytes)
|
||||
contentType = "application/json"
|
||||
|
||||
case c.formBody != nil:
|
||||
form := url.Values{}
|
||||
for k, v := range c.formBody {
|
||||
form.Set(k, ObjToStr(v))
|
||||
}
|
||||
body = strings.NewReader(form.Encode())
|
||||
contentType = "application/x-www-form-urlencoded"
|
||||
}
|
||||
|
||||
req := httptest.NewRequest(method, path, body)
|
||||
if contentType != "" {
|
||||
req.Header.Set("Content-Type", contentType)
|
||||
}
|
||||
|
||||
if c.session != nil {
|
||||
sessionId := Md5("test_session_" + ObjToStr(time.Now().UnixNano()))
|
||||
c.api.app.HoTimeCache.Session(HEAD_SESSION_ADD+sessionId, c.session)
|
||||
req.Header.Set("Authorization", sessionId)
|
||||
}
|
||||
|
||||
return req
|
||||
}
|
||||
|
||||
// === ApiResponse ===
|
||||
|
||||
// ApiResponse 测试响应对象
|
||||
type ApiResponse struct {
|
||||
StatusCode int
|
||||
Body Map
|
||||
RawBody []byte
|
||||
t *testing.T
|
||||
}
|
||||
|
||||
// GetStatus 获取业务状态码(JSON body 中的 status 字段)
|
||||
func (r *ApiResponse) GetStatus() int {
|
||||
return r.Body.GetInt("status")
|
||||
}
|
||||
|
||||
// GetResult 获取响应结果(JSON body 中的 result 字段)
|
||||
func (r *ApiResponse) GetResult() interface{} {
|
||||
return r.Body.Get("result")
|
||||
}
|
||||
|
||||
// GetMsg 获取响应消息
|
||||
// Display 错误格式为 {"status":N, "result":{"msg":"xxx"}},成功时无 msg
|
||||
func (r *ApiResponse) GetMsg() string {
|
||||
if msg := r.Body.GetString("msg"); msg != "" {
|
||||
return msg
|
||||
}
|
||||
if result := r.Body.GetMap("result"); result != nil {
|
||||
return result.GetString("msg")
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// GetBody 获取完整的响应 body
|
||||
func (r *ApiResponse) GetBody() Map {
|
||||
return r.Body
|
||||
}
|
||||
|
||||
// Fail 手动标记测试失败(用于自定义断言)
|
||||
func (r *ApiResponse) Fail(msg string) {
|
||||
if r.t != nil {
|
||||
r.t.Errorf("自定义断言失败: %s\n响应: %s", msg, string(r.RawBody))
|
||||
} else {
|
||||
fmt.Printf("自定义断言失败: %s\n", msg)
|
||||
}
|
||||
}
|
||||
274
testing_helper.go
Normal file
274
testing_helper.go
Normal file
@ -0,0 +1,274 @@
|
||||
package hotime
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
. "code.hoteas.com/golang/hotime/db"
|
||||
)
|
||||
|
||||
// TestApp 测试应用,封装 Application 提供测试能力
|
||||
type TestApp struct {
|
||||
*Application
|
||||
projs TestProj
|
||||
collector *TestCollector
|
||||
}
|
||||
|
||||
// TestResponse httptest 的原始响应封装
|
||||
type TestResponse struct {
|
||||
StatusCode int
|
||||
Body []byte
|
||||
Header http.Header
|
||||
}
|
||||
|
||||
// CoverageReport 覆盖率报告
|
||||
type CoverageReport struct {
|
||||
Total int
|
||||
Covered int
|
||||
Missing []string
|
||||
Details []MethodCoverage
|
||||
}
|
||||
|
||||
// MethodCoverage 单个接口的覆盖详情
|
||||
type MethodCoverage struct {
|
||||
Path string
|
||||
HasTest bool
|
||||
CaseCount int
|
||||
Passed int
|
||||
Failed int
|
||||
Duration time.Duration
|
||||
}
|
||||
|
||||
// TestRecord 单条测试记录
|
||||
type TestRecord struct {
|
||||
Path string
|
||||
Desc string
|
||||
CaseName string
|
||||
Passed bool
|
||||
Duration time.Duration
|
||||
Method string
|
||||
Query map[string]interface{}
|
||||
JsonBody interface{}
|
||||
FormBody map[string]interface{}
|
||||
HasFile bool
|
||||
FileField string
|
||||
ResponseBody map[string]interface{}
|
||||
}
|
||||
|
||||
// TestCollector 线程安全的测试记录收集器
|
||||
type TestCollector struct {
|
||||
mu sync.Mutex
|
||||
Records []TestRecord
|
||||
}
|
||||
|
||||
func (c *TestCollector) Add(r TestRecord) {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
c.Records = append(c.Records, r)
|
||||
}
|
||||
|
||||
// NewTestApp 创建测试应用实例
|
||||
// configPath: 配置文件路径(如 "../config/config.json")
|
||||
// projects: 项目定义(路由 + 测试)
|
||||
// listeners: 可选的请求拦截器(与 SetConnectListener 相同)
|
||||
func NewTestApp(configPath string, projects TestProj, listeners ...func(*Context) bool) *TestApp {
|
||||
app := Init(configPath)
|
||||
|
||||
for _, lis := range listeners {
|
||||
app.SetConnectListener(lis)
|
||||
}
|
||||
|
||||
router := Router{}
|
||||
for projName, projDef := range projects {
|
||||
router[projName] = projDef.Proj
|
||||
}
|
||||
app.SetupForTest(router)
|
||||
|
||||
return &TestApp{
|
||||
Application: app,
|
||||
projs: projects,
|
||||
collector: &TestCollector{},
|
||||
}
|
||||
}
|
||||
|
||||
// SetupForTest 初始化路由(复用 Run 的路由注册逻辑,但不启动 HTTP 服务)
|
||||
func (that *Application) SetupForTest(router Router) {
|
||||
if that.Router == nil {
|
||||
that.Router = Router{}
|
||||
}
|
||||
for k := range router {
|
||||
v := router[k]
|
||||
if that.Router[k] == nil {
|
||||
that.Router[k] = v
|
||||
}
|
||||
for k1 := range v {
|
||||
v1 := v[k1]
|
||||
if that.Router[k][k1] == nil {
|
||||
that.Router[k][k1] = v1
|
||||
}
|
||||
for k2 := range v1 {
|
||||
v2 := v1[k2]
|
||||
that.Router[k][k1][k2] = v2
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
that.MethodRouter = MethodRouter{}
|
||||
modeRouterStrict := true
|
||||
if that.Config.GetBool("modeRouterStrict") == false {
|
||||
modeRouterStrict = false
|
||||
}
|
||||
for pk, pv := range that.Router {
|
||||
if !modeRouterStrict {
|
||||
pk = strings.ToLower(pk)
|
||||
}
|
||||
for ck, cv := range pv {
|
||||
if !modeRouterStrict {
|
||||
ck = strings.ToLower(ck)
|
||||
}
|
||||
for mk, mv := range cv {
|
||||
if !modeRouterStrict {
|
||||
mk = strings.ToLower(mk)
|
||||
}
|
||||
that.MethodRouter["/"+pk+"/"+ck+"/"+mk] = mv
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestRequest 发送测试 HTTP 请求
|
||||
func (that *TestApp) TestRequest(method, path string, body *http.Request) TestResponse {
|
||||
w := httptest.NewRecorder()
|
||||
that.Application.ServeHTTP(w, body)
|
||||
return TestResponse{
|
||||
StatusCode: w.Code,
|
||||
Body: w.Body.Bytes(),
|
||||
Header: w.Header(),
|
||||
}
|
||||
}
|
||||
|
||||
// RunTests 运行所有注册的测试,每个方法级别使用事务隔离
|
||||
func (that *TestApp) RunTests(t *testing.T) {
|
||||
for projName, projDef := range that.projs {
|
||||
projName := projName
|
||||
projDef := projDef
|
||||
t.Run(projName, func(t *testing.T) {
|
||||
for ctrName, ctrTest := range projDef.Tests {
|
||||
ctrName := ctrName
|
||||
ctrTest := ctrTest
|
||||
t.Run(ctrName, func(t *testing.T) {
|
||||
for methodName, apiTest := range ctrTest {
|
||||
methodName := methodName
|
||||
apiTest := apiTest
|
||||
t.Run(methodName, func(t *testing.T) {
|
||||
err := that.Db.BeginTestTx()
|
||||
if err != nil {
|
||||
t.Fatal("开启测试事务失败:", err)
|
||||
}
|
||||
defer that.Db.RollbackTestTx()
|
||||
|
||||
path := "/" + projName + "/" + ctrName + "/" + methodName
|
||||
api := &Api{
|
||||
app: that,
|
||||
path: path,
|
||||
t: t,
|
||||
}
|
||||
t.Run(apiTest.Desc, func(t *testing.T) {
|
||||
api.t = t
|
||||
apiTest.Func(api)
|
||||
})
|
||||
})
|
||||
}
|
||||
})
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// PrintCoverage 输出 API 测试覆盖率报告
|
||||
func (that *TestApp) PrintCoverage() CoverageReport {
|
||||
report := CoverageReport{}
|
||||
|
||||
pathRecords := map[string][]TestRecord{}
|
||||
that.collector.mu.Lock()
|
||||
for _, r := range that.collector.Records {
|
||||
pathRecords[r.Path] = append(pathRecords[r.Path], r)
|
||||
}
|
||||
that.collector.mu.Unlock()
|
||||
|
||||
for projName, projDef := range that.projs {
|
||||
for ctrName, ctr := range projDef.Proj {
|
||||
for methodName := range ctr {
|
||||
path := "/" + projName + "/" + ctrName + "/" + methodName
|
||||
report.Total++
|
||||
|
||||
ctrTest, hasCtr := projDef.Tests[ctrName]
|
||||
if !hasCtr {
|
||||
report.Missing = append(report.Missing, projName+"/"+ctrName+"/"+methodName)
|
||||
report.Details = append(report.Details, MethodCoverage{Path: path, HasTest: false})
|
||||
continue
|
||||
}
|
||||
_, hasMethod := ctrTest[methodName]
|
||||
if !hasMethod {
|
||||
report.Missing = append(report.Missing, projName+"/"+ctrName+"/"+methodName)
|
||||
report.Details = append(report.Details, MethodCoverage{Path: path, HasTest: false})
|
||||
continue
|
||||
}
|
||||
|
||||
report.Covered++
|
||||
mc := MethodCoverage{Path: path, HasTest: true}
|
||||
if records, ok := pathRecords[path]; ok {
|
||||
mc.CaseCount = len(records)
|
||||
for _, r := range records {
|
||||
mc.Duration += r.Duration
|
||||
if r.Passed {
|
||||
mc.Passed++
|
||||
} else {
|
||||
mc.Failed++
|
||||
}
|
||||
}
|
||||
}
|
||||
report.Details = append(report.Details, mc)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fmt.Println("\n========== API 测试覆盖率报告 ==========")
|
||||
if report.Total > 0 {
|
||||
fmt.Printf("总接口: %d | 已覆盖: %d | 覆盖率: %.1f%%\n",
|
||||
report.Total, report.Covered,
|
||||
float64(report.Covered)/float64(report.Total)*100)
|
||||
} else {
|
||||
fmt.Println("总接口: 0")
|
||||
}
|
||||
|
||||
fmt.Println("\n已覆盖的接口:")
|
||||
for _, d := range report.Details {
|
||||
if d.HasTest && d.CaseCount > 0 {
|
||||
fmt.Printf(" + %s\t%d 用例 (%d 通过, %d 失败) %v\n",
|
||||
d.Path, d.CaseCount, d.Passed, d.Failed, d.Duration)
|
||||
} else if d.HasTest {
|
||||
fmt.Printf(" + %s\t(已定义, 未运行)\n", d.Path)
|
||||
}
|
||||
}
|
||||
|
||||
if len(report.Missing) > 0 {
|
||||
fmt.Println("\n未覆盖的接口:")
|
||||
for _, path := range report.Missing {
|
||||
fmt.Println(" -", path)
|
||||
}
|
||||
}
|
||||
fmt.Println("========================================")
|
||||
|
||||
return report
|
||||
}
|
||||
|
||||
// DB 获取测试应用的数据库实例(带 testTx)
|
||||
func (that *TestApp) DB() *HoTimeDB {
|
||||
return &that.Db
|
||||
}
|
||||
508
testing_swagger.go
Normal file
508
testing_swagger.go
Normal file
@ -0,0 +1,508 @@
|
||||
package hotime
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
. "code.hoteas.com/golang/hotime/common"
|
||||
)
|
||||
|
||||
type swaggerTestCase struct {
|
||||
Name string `json:"name"`
|
||||
Method string `json:"method"`
|
||||
Passed bool `json:"passed"`
|
||||
Query map[string]interface{} `json:"query,omitempty"`
|
||||
Json interface{} `json:"json,omitempty"`
|
||||
Form map[string]interface{} `json:"form,omitempty"`
|
||||
HasFile bool `json:"hasFile,omitempty"`
|
||||
FileField string `json:"fileField,omitempty"`
|
||||
Response map[string]interface{} `json:"response,omitempty"`
|
||||
}
|
||||
|
||||
func (that *TestApp) GenerateSwagger(title, version, outputDir string) error {
|
||||
if outputDir == "" {
|
||||
outputDir = "tpt"
|
||||
}
|
||||
swaggerDir := filepath.Join(outputDir, "swagger")
|
||||
if err := os.MkdirAll(swaggerDir, os.ModePerm); err != nil {
|
||||
return fmt.Errorf("创建 swagger 目录失败: %w", err)
|
||||
}
|
||||
|
||||
that.collector.mu.Lock()
|
||||
records := make([]TestRecord, len(that.collector.Records))
|
||||
copy(records, that.collector.Records)
|
||||
that.collector.mu.Unlock()
|
||||
|
||||
pathRecords := map[string][]TestRecord{}
|
||||
for _, r := range records {
|
||||
pathRecords[r.Path] = append(pathRecords[r.Path], r)
|
||||
}
|
||||
|
||||
endpoints := []map[string]interface{}{}
|
||||
|
||||
for projName, projDef := range that.projs {
|
||||
for ctrName, ctr := range projDef.Proj {
|
||||
for methodName := range ctr {
|
||||
apiPath := "/" + projName + "/" + ctrName + "/" + methodName
|
||||
ctrTest, hasCtr := projDef.Tests[ctrName]
|
||||
hasTest := false
|
||||
var apiTest ApiTestDef
|
||||
if hasCtr {
|
||||
apiTest, hasTest = ctrTest[methodName]
|
||||
}
|
||||
|
||||
summary := methodName
|
||||
if hasTest {
|
||||
summary = apiTest.Desc
|
||||
}
|
||||
|
||||
recs := pathRecords[apiPath]
|
||||
httpMethod := "POST"
|
||||
if len(recs) > 0 && recs[0].Method != "" {
|
||||
httpMethod = strings.ToUpper(recs[0].Method)
|
||||
}
|
||||
|
||||
var cases []swaggerTestCase
|
||||
for _, r := range recs {
|
||||
tc := swaggerTestCase{
|
||||
Name: r.CaseName, Method: r.Method, Passed: r.Passed,
|
||||
Response: r.ResponseBody,
|
||||
}
|
||||
if r.Query != nil {
|
||||
tc.Query = r.Query
|
||||
}
|
||||
if r.JsonBody != nil {
|
||||
tc.Json = r.JsonBody
|
||||
}
|
||||
if r.FormBody != nil {
|
||||
tc.Form = r.FormBody
|
||||
}
|
||||
if r.HasFile {
|
||||
tc.HasFile = true
|
||||
tc.FileField = r.FileField
|
||||
}
|
||||
cases = append(cases, tc)
|
||||
}
|
||||
|
||||
endpoints = append(endpoints, map[string]interface{}{
|
||||
"path": apiPath,
|
||||
"project": projName,
|
||||
"ctr": ctrName,
|
||||
"method": httpMethod,
|
||||
"summary": summary,
|
||||
"tested": hasTest,
|
||||
"cases": cases,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
sort.Slice(endpoints, func(i, j int) bool {
|
||||
return endpoints[i]["path"].(string) < endpoints[j]["path"].(string)
|
||||
})
|
||||
|
||||
spec := map[string]interface{}{
|
||||
"title": title,
|
||||
"version": version,
|
||||
"endpoints": endpoints,
|
||||
}
|
||||
|
||||
specJSON, err := json.MarshalIndent(spec, "", " ")
|
||||
if err != nil {
|
||||
return fmt.Errorf("序列化 JSON 失败: %w", err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(swaggerDir, "api-spec.json"), specJSON, os.ModePerm); err != nil {
|
||||
return fmt.Errorf("写入 api-spec.json 失败: %w", err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(swaggerDir, "index.html"), []byte(apiConsoleHTML()), os.ModePerm); err != nil {
|
||||
return fmt.Errorf("写入 index.html 失败: %w", err)
|
||||
}
|
||||
|
||||
fmt.Printf("Swagger 文档已生成: %s\n", swaggerDir)
|
||||
return nil
|
||||
}
|
||||
|
||||
func apiConsoleHTML() string {
|
||||
return `<!DOCTYPE html>
|
||||
<html lang="zh-CN"><head><meta charset="utf-8"><title>API 调试控制台</title>
|
||||
<style>
|
||||
:root{--bg:#1a1a2e;--bg2:#16213e;--bg3:#0f3460;--fg:#e0e0e0;--fg2:#888;--accent:#4fc3f7;--green:#66bb6a;--red:#ef5350;--border:#2a2a4a;--r:4px}
|
||||
*{box-sizing:border-box;margin:0;padding:0}body{display:flex;height:100vh;background:var(--bg);color:var(--fg);font:13px/1.5 -apple-system,sans-serif}
|
||||
input,select,textarea,button{font:inherit}pre{margin:0}
|
||||
|
||||
#side{width:260px;min-width:260px;display:flex;flex-direction:column;border-right:1px solid var(--border)}
|
||||
.shd{padding:10px;border-bottom:1px solid var(--border)}.shd h3{font-size:13px;color:var(--accent);margin-bottom:6px}
|
||||
.shd input{width:100%;padding:5px 8px;border:1px solid var(--border);border-radius:var(--r);background:var(--bg2);color:var(--fg);font-size:12px;outline:none}
|
||||
.shd input:focus{border-color:var(--accent)}.sft{display:flex;gap:2px;margin-top:5px}
|
||||
.sft button{flex:1;padding:3px;border:1px solid var(--border);border-radius:var(--r);background:transparent;color:var(--fg2);font-size:11px;cursor:pointer}
|
||||
.sft button.on{background:var(--bg3);color:var(--accent);border-color:var(--accent)}
|
||||
#tree{flex:1;overflow-y:auto}#tree::-webkit-scrollbar{width:4px}#tree::-webkit-scrollbar-thumb{background:#444;border-radius:2px}
|
||||
.l1{border-bottom:1px solid var(--border)}.l1h,.l2h{display:flex;align-items:center;cursor:pointer}
|
||||
.l1h{padding:5px 8px;font-weight:700;color:var(--accent);font-size:13px}.l1h:hover,.l2h:hover{background:var(--bg2)}
|
||||
.l2h{padding:3px 8px 3px 20px;color:#81d4fa;font-size:12px}
|
||||
.ar{margin-right:4px;font-size:8px;transition:transform .1s;color:#555}.ar.o{transform:rotate(90deg);color:var(--accent)}
|
||||
.cn{margin-left:auto;font-size:10px;color:#444}.sub{display:none}.sub.o{display:block}
|
||||
.ep{display:flex;align-items:center;padding:3px 6px 3px 32px;cursor:pointer;gap:4px;color:var(--fg2)}
|
||||
.ep:hover{background:var(--bg2);color:var(--fg)}.ep.act{background:var(--bg3);color:var(--accent)}
|
||||
.ep .nm{flex:1;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
|
||||
.mt{font-size:9px;padding:1px 4px;border-radius:2px;font-weight:700;flex-shrink:0}
|
||||
.mt-get{background:#1b5e20;color:#a5d6a7}.mt-post{background:#0d47a1;color:#90caf9}.mt-put{background:#e65100;color:#ffcc80}.mt-delete{background:#b71c1c;color:#ef9a9a}
|
||||
.tg{font-size:9px;padding:1px 4px;border-radius:2px;flex-shrink:0}
|
||||
.tg-ok{background:#1b5e20;color:#a5d6a7}.tg-err{background:#b71c1c;color:#ef9a9a}.tg-no{background:#333;color:#555}
|
||||
|
||||
#main{flex:1;display:flex;flex-direction:column;overflow:hidden}
|
||||
#bar{padding:5px 12px;border-bottom:1px solid var(--border);display:flex;align-items:center;gap:6px;flex-shrink:0}
|
||||
#bar label{color:var(--fg2);font-size:11px}
|
||||
#bar select,#bar input{padding:3px 6px;border:1px solid var(--border);border-radius:var(--r);background:var(--bg2);color:var(--fg);font-size:12px;outline:none}
|
||||
#bar input{flex:1;max-width:280px}#bar select{width:auto}
|
||||
#ct{flex:1;overflow-y:auto;padding:12px}#ct::-webkit-scrollbar{width:5px}#ct::-webkit-scrollbar-thumb{background:#444;border-radius:3px}
|
||||
|
||||
.ehd{display:flex;align-items:center;gap:8px;margin-bottom:8px;flex-wrap:wrap}
|
||||
.ehd .mt{font-size:13px;padding:3px 10px}.ehd .pa{font-size:15px;font-weight:600}.ehd .ds{color:var(--fg2);font-size:13px}
|
||||
.ehd .nt{color:var(--red);font-size:11px;padding:2px 6px;border:1px solid var(--red);border-radius:var(--r)}
|
||||
.tabs{display:flex;border-bottom:1px solid var(--border);margin-bottom:10px}
|
||||
.tab{padding:6px 16px;cursor:pointer;color:var(--fg2);border-bottom:2px solid transparent;font-size:13px}
|
||||
.tab:hover{color:var(--fg)}.tab.on{color:var(--accent);border-color:var(--accent)}.pn{display:none}.pn.on{display:block}
|
||||
|
||||
.cs{border:1px solid var(--border);border-radius:var(--r);margin-bottom:6px;overflow:hidden}
|
||||
.csh{padding:6px 10px;display:flex;align-items:center;gap:6px;cursor:pointer;font-size:12px;background:var(--bg2)}
|
||||
.csh:hover{background:var(--bg3)}.csh .dot{width:8px;height:8px;border-radius:50%;flex-shrink:0}
|
||||
.csh .dot.ok{background:var(--green)}.csh .dot.er{background:var(--red)}.csh .ml{margin-left:auto;color:#555;font-size:11px}
|
||||
.csb{display:none;border-top:1px solid var(--border);font-size:12px}.csb.o{display:block}
|
||||
.csr{display:flex}.csr>.csc{flex:1;padding:8px 10px;min-width:0}.csr>.csc+.csc{border-left:1px solid var(--border)}
|
||||
.csc h5{color:var(--accent);margin:0 0 4px;font-size:11px;letter-spacing:.5px;display:flex;align-items:center}
|
||||
.csc h5 .cp-sm{margin-left:auto}.csc h5:not(:first-child){margin-top:8px}
|
||||
pre.j{background:#0a0a1a;padding:6px 8px;border-radius:var(--r);font-size:12px;line-height:1.4;color:#aaa;white-space:pre-wrap;word-break:break-all;max-height:260px;overflow:auto;font-family:Consolas,Monaco,monospace}
|
||||
|
||||
.dbg{display:flex;gap:12px}
|
||||
.dbg-l{flex:1;min-width:0;max-height:calc(100vh - 140px);overflow-y:auto;padding-right:4px}
|
||||
.dbg-r{flex:1;min-width:0;max-height:calc(100vh - 140px);overflow-y:auto}
|
||||
.dbg-l::-webkit-scrollbar,.dbg-r::-webkit-scrollbar{width:4px}.dbg-l::-webkit-scrollbar-thumb,.dbg-r::-webkit-scrollbar-thumb{background:#444;border-radius:2px}
|
||||
.sec{margin-top:10px;padding-top:10px;border-top:1px solid var(--border)}.sec-title{color:var(--fg2);font-size:11px;letter-spacing:.5px;margin-bottom:4px}
|
||||
.row-hd{display:flex;align-items:center;margin-bottom:4px;padding:4px 8px;background:var(--bg2);border-radius:var(--r)}.row-hd .sec-title{flex:1;margin:0}
|
||||
.kv{display:flex;gap:4px;margin-bottom:3px}.kv input{flex:1;padding:4px 6px;border:1px solid var(--border);border-radius:var(--r);background:var(--bg2);color:var(--fg);font-size:12px;outline:none}
|
||||
.kv input[type="file"]{padding:3px 4px}
|
||||
.kv input:focus{border-color:var(--accent)}.kv button{padding:2px 8px;background:var(--bg3);border:1px solid var(--border);color:var(--red);border-radius:var(--r);cursor:pointer}
|
||||
.addbtn{padding:2px 10px;background:var(--bg2);border:1px solid var(--border);border-radius:var(--r);color:var(--fg);cursor:pointer;font-size:10px;white-space:nowrap}
|
||||
.addbtn:hover{border-color:var(--accent);color:var(--accent)}
|
||||
.sbtn{padding:6px 20px;background:var(--accent);color:var(--bg);border:none;border-radius:var(--r);cursor:pointer;font-size:12px;font-weight:600;white-space:nowrap}
|
||||
.sbtn:hover{opacity:.9}.sbtn:disabled{opacity:.4;cursor:not-allowed}
|
||||
.curl{background:#0a0a1a;padding:6px 8px;border-radius:var(--r);font-size:11px;color:#888;white-space:pre-wrap;word-break:break-all;font-family:Consolas,Monaco,monospace}
|
||||
.cp-sm{padding:1px 6px;background:var(--bg3);border:1px solid var(--border);border-radius:var(--r);color:var(--fg2);cursor:pointer;font-size:10px}
|
||||
.cp-sm:hover{color:var(--accent);border-color:var(--accent)}
|
||||
.pc{border:1px solid var(--border);border-radius:var(--r);margin-bottom:6px;overflow:hidden}
|
||||
.pc-hd{padding:3px 8px;background:var(--bg2);color:var(--fg2);font-size:10px;letter-spacing:.5px;border-bottom:1px solid var(--border)}.pc-bd{padding:6px 8px}
|
||||
.kv-ro{display:flex;gap:4px;margin-bottom:3px}
|
||||
.kv-ro .kk{min-width:80px;max-width:140px;padding:4px 6px;border:1px solid var(--border);border-radius:var(--r);background:var(--bg3);color:var(--accent);font-size:12px;word-break:break-all}
|
||||
.kv-ro .vv{flex:1;padding:4px 6px;border:1px solid var(--border);border-radius:var(--r);background:var(--bg2);color:var(--fg);font-size:12px;word-break:break-all}
|
||||
.bd-acc{border:1px solid var(--border);border-radius:var(--r);overflow:hidden}
|
||||
.bd-hd{padding:6px 8px;cursor:pointer;display:flex;align-items:center;gap:5px;color:var(--fg2);font-size:11px;background:var(--bg2);user-select:none}
|
||||
.bd-hd+.bd-hd,.bd-bd+.bd-hd{border-top:1px solid var(--border)}
|
||||
.bd-hd.on{color:var(--accent)}.bd-hd .ar{font-size:8px;transition:transform .15s;color:#555;margin-right:2px}.bd-hd.on .ar{transform:rotate(90deg);color:var(--accent)}
|
||||
.bd-hd .addbtn{margin-left:auto}
|
||||
.bd-bd{display:none;padding:8px;border-top:1px solid var(--border)}.bd-bd.on{display:block}
|
||||
.bd-bd textarea{width:100%;min-height:36px;max-height:40vh;padding:5px 8px;border:1px solid var(--border);border-radius:var(--r);background:var(--bg2);color:var(--fg);font-size:12px;font-family:Consolas,Monaco,monospace;resize:vertical;outline:none;overflow-y:auto}
|
||||
.bd-bd textarea:focus{border-color:var(--accent)}
|
||||
</style></head><body>
|
||||
|
||||
<div id="side">
|
||||
<div class="shd">
|
||||
<h3 id="dt"></h3>
|
||||
<input id="q" placeholder="搜索接口..." oninput="render()">
|
||||
<div class="sft">
|
||||
<button class="on" onclick="sf('all',this)">全部</button>
|
||||
<button onclick="sf('yes',this)">已测试</button>
|
||||
<button onclick="sf('no',this)">未测试</button>
|
||||
</div>
|
||||
</div>
|
||||
<div id="tree"></div>
|
||||
</div>
|
||||
|
||||
<div id="main">
|
||||
<div id="bar">
|
||||
<label>认证</label>
|
||||
<select id="at" onchange="syncAuth()"><option value="none">无认证</option><option value="header">Header (Authorization)</option><option value="query">URL (?token=)</option><option value="cookie">Cookie</option></select>
|
||||
<input id="tk" placeholder="Token(登录后返回的 32 位字符串)" oninput="syncAuth()">
|
||||
</div>
|
||||
<div id="ct"><div style="text-align:center;color:var(--fg2);padding:80px 20px">← 选择一个接口开始</div></div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
let D=null,F='all',C=null;
|
||||
|
||||
fetch('./api-spec.json').then(r=>r.json()).then(d=>{
|
||||
D=d;document.getElementById('dt').textContent=d.title+' v'+d.version;document.title=d.title;render();
|
||||
});
|
||||
|
||||
function render(){
|
||||
if(!D)return;const q=document.getElementById('q').value.toLowerCase();const T={};
|
||||
for(const e of D.endpoints){
|
||||
if(F==='yes'&&!e.tested||F==='no'&&e.tested)continue;
|
||||
if(q&&!e.summary.toLowerCase().includes(q)&&!e.path.toLowerCase().includes(q)&&!e.ctr.toLowerCase().includes(q))continue;
|
||||
if(!T[e.project])T[e.project]={};if(!T[e.project][e.ctr])T[e.project][e.ctr]=[];T[e.project][e.ctr].push(e);
|
||||
}
|
||||
let h='';
|
||||
for(const p of Object.keys(T).sort()){let ph='';
|
||||
for(const c of Object.keys(T[p]).sort()){const es=T[p][c];let eh='';
|
||||
for(const e of es){const m=e.method.toLowerCase();
|
||||
let tg='';if(!e.tested)tg='<span class="tg tg-no">未测试</span>';
|
||||
else if(e.cases?.length){const pc=e.cases.filter(x=>x.passed).length;tg=pc===e.cases.length?'<span class="tg tg-ok">'+pc+'/'+e.cases.length+'</span>':'<span class="tg tg-err">'+pc+'/'+e.cases.length+'</span>';}
|
||||
const mn=e.path.split('/').pop();const lbl=e.tested&&e.summary!==mn?mn+' '+e.summary:e.summary;
|
||||
eh+='<div class="ep'+(C?.path===e.path?' act':'')+'" data-p="'+e.path+'" onclick="go(this)"><span class="mt mt-'+m+'">'+e.method+'</span><span class="nm" title="'+e.path+'">'+lbl+'</span>'+tg+'</div>';
|
||||
}
|
||||
ph+='<div class="l2h" onclick="tog(this)"><span class="ar o">▶</span>'+c+'<span class="cn">'+es.length+'</span></div><div class="sub o">'+eh+'</div>';
|
||||
}
|
||||
h+='<div class="l1"><div class="l1h" onclick="tog(this)"><span class="ar o">▶</span>'+p+'</div><div class="sub o">'+ph+'</div></div>';
|
||||
}
|
||||
document.getElementById('tree').innerHTML=h||'<div style="padding:30px;color:#555;text-align:center">无匹配</div>';
|
||||
}
|
||||
function sf(f,b){F=f;document.querySelectorAll('.sft button').forEach(x=>x.classList.remove('on'));b.classList.add('on');render();}
|
||||
function tog(e){e.querySelector('.ar').classList.toggle('o');e.nextElementSibling.classList.toggle('o');}
|
||||
function go(el){C=D.endpoints.find(e=>e.path===el.dataset.p);if(!C)return;document.querySelectorAll('.ep').forEach(e=>e.classList.remove('act'));el.classList.add('act');show();}
|
||||
|
||||
function show(){
|
||||
const e=C;if(!e)return;const m=e.method.toLowerCase();
|
||||
let h='<div class="ehd"><span class="mt mt-'+m+'" style="font-size:14px;padding:4px 10px">'+e.method+'</span><span class="pa">'+e.path+'</span><span class="ds">'+e.summary+'</span>';
|
||||
if(!e.tested)h+='<span class="nt">未测试</span>';
|
||||
h+='</div><div class="tabs"><div class="tab on" onclick="stab(0,this)">测试用例'+(e.cases?'('+e.cases.length+')':'')+'</div><div class="tab" onclick="stab(1,this)">接口调试</div></div>';
|
||||
h+='<div class="pn on" id="p0">'+cases(e)+'</div><div class="pn" id="p1">'+exec(e)+'</div>';
|
||||
document.getElementById('ct').innerHTML=h;
|
||||
}
|
||||
function stab(i,el){document.querySelectorAll('.tab').forEach(t=>t.classList.remove('on'));document.querySelectorAll('.pn').forEach(p=>p.classList.remove('on'));el.classList.add('on');document.getElementById('p'+i).classList.add('on');}
|
||||
|
||||
function cases(e){
|
||||
if(!e.cases?.length)return '<div style="color:#555;padding:20px">暂无测试用例</div>';
|
||||
const _at=document.getElementById('at')?.value||'header';
|
||||
const _tk=document.getElementById('tk')?.value||'';
|
||||
let h='';
|
||||
for(let i=0;i<e.cases.length;i++){const c=e.cases[i];
|
||||
const ct=c.json?'application/json':c.form||c.hasFile?'multipart/form-data':'';
|
||||
let hdr='Content-Type: '+(ct||'none');
|
||||
if(_tk&&_at!=='none'){if(_at==='header')hdr+='\nAuthorization: '+_tk;else if(_at==='cookie')hdr+='\nCookie: HOTIME='+_tk;else if(_at==='query')hdr+='\n(token via URL ?token='+_tk+')';}
|
||||
h+='<div class="cs"><div class="csh" onclick="accordion(this)"><span class="ar'+(i===0?' o':'')+'">▶</span><span class="dot '+(c.passed?'ok':'er')+'"></span>'+esc(c.name)+'<span class="ml">'+c.method+'</span></div>';
|
||||
h+='<div class="csb'+(i===0?' o':'')+'"><div class="csr"><div class="csc">';
|
||||
h+='<h5>请求头 <button class="cp-sm" onclick="event.stopPropagation();cpNext(this)">复制</button></h5>';
|
||||
h+='<pre class="j">'+esc(hdr)+'</pre>';
|
||||
h+='<h5>请求参数 <button class="cp-sm" onclick="event.stopPropagation();cpNext(this)">复制</button></h5>';
|
||||
if(c.query)h+='<div class="pc"><div class="pc-hd">QUERY 参数</div><div class="pc-bd">'+kvList(c.query)+'</div></div>';
|
||||
if(c.json)h+='<div class="pc"><div class="pc-hd">JSON BODY</div><div class="pc-bd"><pre class="j" style="margin:0">'+fj(c.json)+'</pre></div></div>';
|
||||
if(c.form)h+='<div class="pc"><div class="pc-hd">FORM 表单</div><div class="pc-bd">'+kvList(c.form)+'</div></div>';
|
||||
if(c.hasFile)h+='<div class="pc"><div class="pc-hd">文件上传</div><div class="pc-bd"><div class="kv-ro"><span class="kk">字段名</span><span class="vv">'+esc(c.fileField)+'</span></div></div></div>';
|
||||
if(!c.query&&!c.json&&!c.form&&!c.hasFile)h+='<span style="color:#555">无参数</span>';
|
||||
h+='</div><div class="csc"><h5>响应 <button class="cp-sm" onclick="event.stopPropagation();cpNext(this)">复制</button></h5>';
|
||||
if(c.response)h+='<pre class="j">'+fj(c.response)+'</pre>';
|
||||
else h+='<span style="color:#555">无</span>';
|
||||
h+='</div></div></div></div>';
|
||||
}
|
||||
return h;
|
||||
}
|
||||
function accordion(el){
|
||||
const bd=el.nextElementSibling;const wasOpen=bd.classList.contains('o');
|
||||
el.closest('.pn').querySelectorAll('.csb').forEach(b=>b.classList.remove('o'));
|
||||
el.closest('.pn').querySelectorAll('.csh .ar').forEach(a=>a.classList.remove('o'));
|
||||
if(!wasOpen){bd.classList.add('o');el.querySelector('.ar').classList.add('o');}
|
||||
}
|
||||
|
||||
function exec(e){
|
||||
let opts='<option value="">-- 手动填写 --</option>';
|
||||
if(e.cases)for(let i=0;i<e.cases.length;i++)opts+='<option value="'+i+'">'+esc(e.cases[i].name)+'</option>';
|
||||
const hasForm=e.cases?.some(c=>c.form);
|
||||
const defBody=hasForm?'form':'json';
|
||||
const tk=document.getElementById('tk').value||'';
|
||||
const at=document.getElementById('at').value;
|
||||
|
||||
let l='';
|
||||
l+='<div style="display:flex;align-items:center;gap:6px;margin-bottom:10px">';
|
||||
l+='<span style="color:var(--fg2);font-size:11px;white-space:nowrap">预填用例</span>';
|
||||
l+='<select id="pf" onchange="pf()" style="flex:1;padding:4px 6px;border:1px solid var(--border);border-radius:var(--r);background:var(--bg2);color:var(--fg);font-size:12px;outline:none">'+opts+'</select>';
|
||||
l+='<button class="sbtn" onclick="send()" id="sbtn">发送请求</button>';
|
||||
l+='</div>';
|
||||
|
||||
let defH='<div class="kv"><input value="Content-Type"><input id="ct-val" value="'+(defBody==='json'?'application/json':'application/x-www-form-urlencoded')+'"><button onclick="this.parentElement.remove()">×</button></div>';
|
||||
if(tk&&at==='header')defH+='<div class="kv" data-auth="1"><input value="Authorization"><input value="'+esc(tk)+'"><button onclick="this.parentElement.remove()">×</button></div>';
|
||||
if(tk&&at==='cookie')defH+='<div class="kv" data-auth="1"><input value="Cookie"><input value="HOTIME='+esc(tk)+'"><button onclick="this.parentElement.remove()">×</button></div>';
|
||||
l+='<div class="sec" style="margin-top:0"><div class="row-hd"><span class="sec-title">请求头 (Headers)</span><button class="addbtn" onclick="addKV(\'h-rows\')">+ 添加</button></div><div id="h-rows">'+defH+'</div></div>';
|
||||
|
||||
let qDef='';
|
||||
if(tk&&at==='query')qDef='<div class="kv" data-auth="1"><input value="token"><input value="'+esc(tk)+'"><button onclick="this.parentElement.remove()">×</button></div>';
|
||||
qDef+='<div class="kv"><input placeholder="key"><input placeholder="value"><button onclick="this.parentElement.remove()">×</button></div>';
|
||||
l+='<div class="sec"><div class="row-hd"><span class="sec-title">Query 参数</span><button class="addbtn" onclick="addKV(\'q-rows\')">+ 添加</button></div><div id="q-rows">'+qDef+'</div></div>';
|
||||
|
||||
l+='<div class="sec"><div class="row-hd"><span class="sec-title">文件上传</span><button class="addbtn" onclick="addFileRow()">+ 添加</button></div><div id="file-rows"><div class="kv"><input placeholder="字段名" value="file" style="max-width:120px"><input type="file"><button onclick="this.parentElement.remove()">×</button></div></div></div>';
|
||||
|
||||
l+='<div class="sec"><div class="sec-title" style="margin-bottom:4px">请求体</div>';
|
||||
l+='<div class="bd-acc">';
|
||||
l+='<div class="bd-hd'+(defBody==='form'?' on':'')+'" data-t="form" onclick="togBody(\'form\')"><span class="ar'+(defBody==='form'?' o':'')+'">▶</span>表单 (Form)<button class="addbtn" onclick="event.stopPropagation();setAcc(\'form\');addKV(\'f-rows\')">+ 添加</button></div>';
|
||||
l+='<div id="bt-form" class="bd-bd'+(defBody==='form'?' on':'')+'"><div id="f-rows"><div class="kv"><input placeholder="key"><input placeholder="value"><button onclick="this.parentElement.remove()">×</button></div></div></div>';
|
||||
l+='<div class="bd-hd'+(defBody==='json'?' on':'')+'" data-t="json" onclick="togBody(\'json\')"><span class="ar'+(defBody==='json'?' o':'')+'">▶</span>JSON (application/json)</div>';
|
||||
l+='<div id="bt-json" class="bd-bd'+(defBody==='json'?' on':'')+'"><textarea id="xb" oninput="autoH(this)" placeholder=\'{"key":"value"}\'></textarea></div>';
|
||||
l+='</div></div>';
|
||||
|
||||
let r='';
|
||||
r+='<div class="row-hd" style="margin-bottom:4px"><span class="sec-title">cURL</span><button class="cp-sm" onclick="copyEl(\'curl-box\')">复制</button></div>';
|
||||
r+='<div class="curl" id="curl-box">发送请求后生成</div>';
|
||||
r+='<div class="row-hd" style="margin:10px 0 4px"><span class="sec-title">响应 <span id="resp-st"></span></span><button class="cp-sm" onclick="copyEl(\'resp-body\')">复制</button></div>';
|
||||
r+='<pre class="j" id="resp-body" style="min-height:60px;max-height:calc(100vh - 280px)">发送请求后显示</pre>';
|
||||
|
||||
return '<div class="dbg"><div class="dbg-l">'+l+'</div><div class="dbg-r">'+r+'</div></div>';
|
||||
}
|
||||
|
||||
function addKV(id,k,v){
|
||||
const row=document.createElement('div');row.className='kv';
|
||||
row.innerHTML='<input placeholder="key" value="'+esc(k||'')+'"><input placeholder="value" value="'+esc(v||'')+'"><button onclick="this.parentElement.remove()">×</button>';
|
||||
document.getElementById(id).appendChild(row);
|
||||
}
|
||||
function addFileRow(field){
|
||||
const row=document.createElement('div');row.className='kv';
|
||||
row.innerHTML='<input placeholder="字段名" value="'+esc(field||'file')+'" style="max-width:120px"><input type="file"><button onclick="this.parentElement.remove()">×</button>';
|
||||
document.getElementById('file-rows').appendChild(row);
|
||||
}
|
||||
function setAcc(t){
|
||||
document.querySelectorAll('.bd-hd').forEach(h=>{h.classList.toggle('on',h.dataset.t===t);const a=h.querySelector('.ar');if(a)a.classList.toggle('o',h.dataset.t===t);});
|
||||
document.getElementById('bt-json').classList.toggle('on',t==='json');
|
||||
document.getElementById('bt-form').classList.toggle('on',t==='form');
|
||||
const ctVal=document.getElementById('ct-val');
|
||||
if(ctVal)ctVal.value=t==='json'?'application/json':'application/x-www-form-urlencoded';
|
||||
}
|
||||
function togBody(t){
|
||||
const cur=document.getElementById(t==='json'?'bt-json':'bt-form').classList.contains('on');
|
||||
setAcc(cur?(t==='json'?'form':'json'):t);
|
||||
}
|
||||
function autoH(el){el.style.height='36px';el.style.height=Math.min(el.scrollHeight,window.innerHeight*0.4)+'px';}
|
||||
function syncAuth(){
|
||||
const tk=document.getElementById('tk').value;const at=document.getElementById('at').value;
|
||||
const hr=document.getElementById('h-rows');const qr=document.getElementById('q-rows');
|
||||
if(!hr||!qr)return;
|
||||
hr.querySelectorAll('[data-auth]').forEach(e=>e.remove());
|
||||
qr.querySelectorAll('[data-auth]').forEach(e=>e.remove());
|
||||
if(!tk)return;
|
||||
function mkAuth(k,v,parent){const d=document.createElement('div');d.className='kv';d.dataset.auth='1';d.innerHTML='<input value="'+esc(k)+'"><input value="'+esc(v)+'"><button onclick="this.parentElement.remove()">×</button>';parent.insertBefore(d,parent.querySelector('.kv:last-child'));}
|
||||
if(at==='header')mkAuth('Authorization',tk,hr);
|
||||
if(at==='cookie')mkAuth('Cookie','HOTIME='+tk,hr);
|
||||
if(at==='query')mkAuth('token',tk,qr);
|
||||
}
|
||||
|
||||
function pf(){
|
||||
const i=parseInt(document.getElementById('pf').value);
|
||||
if(isNaN(i)||i<0||!C?.cases?.[i])return;const c=C.cases[i];
|
||||
document.getElementById('q-rows').innerHTML='';
|
||||
if(c.query)for(const[k,v]of Object.entries(c.query))addKV('q-rows',k,String(v));
|
||||
addKV('q-rows');
|
||||
if(c.json){
|
||||
setAcc('json');const el=document.getElementById('xb');if(el){el.value=JSON.stringify(c.json,null,2);autoH(el);}
|
||||
}else if(c.form){
|
||||
setAcc('form');const fr=document.getElementById('f-rows');
|
||||
if(fr){fr.innerHTML='';for(const[k,v]of Object.entries(c.form))addKV('f-rows',k,String(v));addKV('f-rows');}
|
||||
}else{
|
||||
const el=document.getElementById('xb');if(el)el.value='';
|
||||
const fr=document.getElementById('f-rows');if(fr){fr.innerHTML='';addKV('f-rows');}
|
||||
}
|
||||
document.getElementById('file-rows').innerHTML='';
|
||||
if(c.hasFile&&c.fileField)addFileRow(c.fileField);
|
||||
addFileRow();
|
||||
syncAuth();
|
||||
}
|
||||
|
||||
function getKV(id){
|
||||
const obj={};
|
||||
document.querySelectorAll('#'+id+' .kv').forEach(r=>{const ins=r.querySelectorAll('input');const k=ins[0].value.trim(),v=ins[1].value;if(k)obj[k]=v;});
|
||||
return Object.keys(obj).length?obj:null;
|
||||
}
|
||||
|
||||
async function send(){
|
||||
if(!C)return;const btn=document.getElementById('sbtn');btn.disabled=true;btn.textContent='请求中...';
|
||||
let url=C.path;
|
||||
const qp=getKV('q-rows');
|
||||
if(qp){const ps=new URLSearchParams();for(const[k,v]of Object.entries(qp))ps.set(k,v);url+='?'+ps.toString();}
|
||||
|
||||
const token=document.getElementById('tk').value;
|
||||
const authType=document.getElementById('at').value;
|
||||
|
||||
const opts={method:C.method,headers:{}};
|
||||
if(authType==='cookie'){opts.credentials='same-origin';if(token)document.cookie='HOTIME='+token+';path=/';}
|
||||
else{opts.credentials='omit';}
|
||||
|
||||
const ch=getKV('h-rows');if(ch)for(const[k,v]of Object.entries(ch)){
|
||||
if(k.toLowerCase()==='authorization'&&token&&authType==='header')continue;
|
||||
if(k.toLowerCase()==='cookie'&&token&&authType==='cookie')continue;
|
||||
opts.headers[k]=v;
|
||||
}
|
||||
if(token&&authType==='header')opts.headers['Authorization']=token;
|
||||
|
||||
const fileEls=document.querySelectorAll('#file-rows .kv');
|
||||
const files=[];fileEls.forEach(r=>{const fn=r.querySelector('input[type="text"]').value.trim();const fi=r.querySelector('input[type="file"]');if(fn&&fi.files.length)files.push({field:fn,file:fi.files[0]});});
|
||||
|
||||
const isJson=document.getElementById('bt-json')?.classList.contains('on');
|
||||
const bodyEl=document.getElementById('xb');const formKV=getKV('f-rows');let curlParts='';
|
||||
|
||||
if(files.length){
|
||||
const fd=new FormData();for(const f of files)fd.append(f.field,f.file);
|
||||
if(!isJson&&formKV)for(const[k,v]of Object.entries(formKV))fd.append(k,v);
|
||||
opts.body=fd;delete opts.headers['Content-Type'];
|
||||
for(const f of files)curlParts+=' -F "'+f.field+'=@'+f.file.name+'"';
|
||||
if(!isJson&&formKV)for(const[k,v]of Object.entries(formKV))curlParts+=' -F "'+k+'='+v+'"';
|
||||
}else if(isJson){
|
||||
const b=bodyEl?.value?.trim();
|
||||
if(b&&C.method!=='GET'){opts.body=b;curlParts=" -d '"+b+"'";}
|
||||
}else if(formKV){
|
||||
const fd=new FormData();for(const[k,v]of Object.entries(formKV))fd.append(k,v);opts.body=fd;delete opts.headers['Content-Type'];
|
||||
for(const[k,v]of Object.entries(formKV))curlParts+=' -F "'+k+'='+v+'"';
|
||||
}
|
||||
|
||||
let curl='curl -X '+C.method;
|
||||
for(const[k,v]of Object.entries(opts.headers)){if(k&&v)curl+=" -H '"+k+": "+v+"'";}
|
||||
if(token&&authType==='cookie')curl+=" --cookie 'HOTIME="+token+"'";
|
||||
curl+=curlParts+' '+location.origin+url;
|
||||
document.getElementById('curl-box').textContent=curl;
|
||||
|
||||
const t0=Date.now();
|
||||
try{
|
||||
const r=await fetch(url,opts);const ms=Date.now()-t0;const txt=await r.text();let pretty=txt;
|
||||
try{pretty=JSON.stringify(JSON.parse(txt),null,2);}catch(e){}
|
||||
document.getElementById('resp-st').innerHTML='<span style="color:'+(r.ok?'var(--green)':'var(--red)')+'">HTTP '+r.status+'</span> <span style="color:#555">'+ms+'ms</span>';
|
||||
document.getElementById('resp-body').textContent=pretty;
|
||||
}catch(e){
|
||||
document.getElementById('resp-st').innerHTML='<span style="color:var(--red)">失败</span>';
|
||||
document.getElementById('resp-body').textContent=e.message;
|
||||
}
|
||||
btn.disabled=false;btn.textContent='发送请求';
|
||||
}
|
||||
|
||||
function copyEl(id){navigator.clipboard.writeText(document.getElementById(id).textContent).then(()=>{const b=event.target;b.textContent='已复制';setTimeout(()=>{b.textContent='复制'},1200);});}
|
||||
function cpNext(btn){let el=btn.closest('h5').nextElementSibling;let t='';while(el&&el.tagName!=='H5'){if(el.tagName==='PRE')t+=(t?'\n':'')+el.textContent;el=el.nextElementSibling;}navigator.clipboard.writeText(t).then(()=>{btn.textContent='已复制';setTimeout(()=>{btn.textContent='复制'},1200);});}
|
||||
function fj(o){return esc(JSON.stringify(o,null,2));}
|
||||
function kvList(o){let h='';for(const[k,v]of Object.entries(o))h+='<div class="kv-ro"><span class="kk">'+esc(k)+'</span><span class="vv">'+esc(String(v))+'</span></div>';return h;}
|
||||
function esc(s){return String(s).replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"');}
|
||||
</script></body></html>`
|
||||
}
|
||||
|
||||
func inferSchema(v interface{}) map[string]interface{} {
|
||||
if v == nil {
|
||||
return map[string]interface{}{"type": "string"}
|
||||
}
|
||||
switch v.(type) {
|
||||
case int, int64, int32, float64, float32:
|
||||
return map[string]interface{}{"type": "number"}
|
||||
case bool:
|
||||
return map[string]interface{}{"type": "boolean"}
|
||||
case map[string]interface{}, Map:
|
||||
props := map[string]interface{}{}
|
||||
var m map[string]interface{}
|
||||
switch val := v.(type) {
|
||||
case Map:
|
||||
m = map[string]interface{}(val)
|
||||
case map[string]interface{}:
|
||||
m = val
|
||||
}
|
||||
for k, val := range m {
|
||||
props[k] = inferSchema(val)
|
||||
}
|
||||
return map[string]interface{}{"type": "object", "properties": props}
|
||||
default:
|
||||
return map[string]interface{}{"type": "string"}
|
||||
}
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user