All checks were successful
构建并部署到测试环境(无 SSH) / build-and-deploy (push) Successful in 6m29s
主要变更: - 新增 tb_shop_series_allocation 表,存储系列级别的一次性佣金配置 - ShopPackageAllocation 移除 one_time_commission_amount 字段 - PackageSeries 新增 enable_one_time_commission 字段控制是否启用一次性佣金 - 新增 /api/admin/shop-series-allocations CRUD 接口 - 佣金计算逻辑改为从 ShopSeriesAllocation 获取一次性佣金金额 - 删除废弃的 ShopSeriesOneTimeCommissionTier 模型 - OpenAPI Tag '系列分配' 和 '单套餐分配' 合并为 '套餐分配' 迁移脚本: - 000042: 重构佣金套餐模型 - 000043: 简化佣金分配 - 000044: 一次性佣金分配重构 - 000045: PackageSeries 添加 enable_one_time_commission 字段 测试: - 新增验收测试 (shop_series_allocation, commission_calculation) - 新增流程测试 (one_time_commission_chain) - 删除过时的单元测试(已被验收测试覆盖)
542 lines
15 KiB
Markdown
542 lines
15 KiB
Markdown
# 业务流程测试 (Flow Tests)
|
||
|
||
流程测试验证多个 API 组合的完整业务场景,确保端到端流程正确。
|
||
|
||
## 核心原则
|
||
|
||
1. **来源于 Spec Business Flow**:每个测试对应 Spec 中的一个 Business Flow
|
||
2. **跨 API 验证**:验证多个 API 调用的组合行为
|
||
3. **状态共享**:流程中的数据(如 ID)在 steps 之间传递
|
||
4. **角色切换**:不同 step 可能由不同角色执行
|
||
5. **必须有破坏点和依赖声明**
|
||
|
||
## 目录结构
|
||
|
||
```
|
||
tests/flows/
|
||
├── README.md # 本文件
|
||
├── package_lifecycle_flow_test.go # 套餐完整生命周期
|
||
├── order_purchase_flow_test.go # 订单购买流程
|
||
├── commission_settlement_flow_test.go # 佣金结算流程
|
||
├── iot_card_import_activate_flow_test.go # IoT 卡导入激活流程
|
||
└── ...
|
||
```
|
||
|
||
## 测试模板
|
||
|
||
```go
|
||
package flows
|
||
|
||
import (
|
||
"testing"
|
||
|
||
"github.com/stretchr/testify/assert"
|
||
"github.com/stretchr/testify/require"
|
||
|
||
"junhong_cmp_fiber/tests/testutils"
|
||
)
|
||
|
||
// ============================================================
|
||
// 流程测试:{流程名称}
|
||
// 来源:openspec/changes/{change-name}/specs/{capability}/spec.md
|
||
// 参与者:{角色1}, {角色2}, ...
|
||
// ============================================================
|
||
|
||
func TestFlow_{FlowName}(t *testing.T) {
|
||
env := testutils.NewIntegrationTestEnv(t)
|
||
|
||
// ========================================================
|
||
// 流程级共享状态
|
||
// 在 steps 之间传递的数据
|
||
// ========================================================
|
||
var (
|
||
resourceID uint
|
||
orderID uint
|
||
// 其他需要共享的状态...
|
||
)
|
||
|
||
// ------------------------------------------------------------
|
||
// Step 1: {步骤名称}
|
||
// 角色: {执行角色}
|
||
// 调用: {HTTP Method} {Path}
|
||
// 预期: {预期结果}
|
||
//
|
||
// 依赖: 无(首个步骤)
|
||
// 破坏点:{描述什么代码变更会导致此测试失败}
|
||
// ------------------------------------------------------------
|
||
t.Run("Step1_{步骤名称}", func(t *testing.T) {
|
||
client := env.AsSuperAdmin() // 或其他角色
|
||
|
||
body := map[string]interface{}{
|
||
// 请求体
|
||
}
|
||
resp, err := client.Request("POST", "/api/admin/xxx", body)
|
||
require.NoError(t, err)
|
||
require.Equal(t, 200, resp.StatusCode)
|
||
|
||
var result map[string]interface{}
|
||
err = resp.JSON(&result)
|
||
require.NoError(t, err)
|
||
|
||
// 提取共享状态
|
||
data := result["data"].(map[string]interface{})
|
||
resourceID = uint(data["id"].(float64))
|
||
require.NotZero(t, resourceID, "资源 ID 不能为空")
|
||
})
|
||
|
||
// ------------------------------------------------------------
|
||
// Step 2: {步骤名称}
|
||
// 角色: {执行角色}
|
||
// 调用: {HTTP Method} {Path}
|
||
// 预期: {预期结果}
|
||
//
|
||
// 依赖: Step 1 的 resourceID
|
||
// 破坏点:{描述什么代码变更会导致此测试失败}
|
||
// ------------------------------------------------------------
|
||
t.Run("Step2_{步骤名称}", func(t *testing.T) {
|
||
if resourceID == 0 {
|
||
t.Skip("依赖 Step 1 创建的 resourceID")
|
||
}
|
||
|
||
client := env.AsShopAgent(1) // 切换到代理商角色
|
||
|
||
resp, err := client.Request("GET", fmt.Sprintf("/api/admin/xxx/%d", resourceID), nil)
|
||
require.NoError(t, err)
|
||
require.Equal(t, 200, resp.StatusCode)
|
||
|
||
// 验证和提取数据...
|
||
})
|
||
|
||
// 更多 steps...
|
||
}
|
||
```
|
||
|
||
## 流程测试 vs 验收测试
|
||
|
||
| 方面 | 验收测试 | 流程测试 |
|
||
|------|---------|---------|
|
||
| 来源 | Spec Scenario | Spec Business Flow |
|
||
| 粒度 | 单 API | 多 API 组合 |
|
||
| 状态 | 独立 | steps 之间共享 |
|
||
| 角色 | 通常单一 | 可能多角色 |
|
||
| 目的 | 验证 API 契约 | 验证业务场景 |
|
||
|
||
## 状态共享模式
|
||
|
||
### 使用包级变量
|
||
|
||
```go
|
||
func TestFlow_PackageLifecycle(t *testing.T) {
|
||
env := testutils.NewIntegrationTestEnv(t)
|
||
|
||
// 流程级共享状态
|
||
var (
|
||
packageID uint
|
||
allocationID uint
|
||
orderID uint
|
||
)
|
||
|
||
t.Run("Step1_创建套餐", func(t *testing.T) {
|
||
// ... 创建套餐
|
||
packageID = extractedID
|
||
})
|
||
|
||
t.Run("Step2_分配套餐", func(t *testing.T) {
|
||
if packageID == 0 {
|
||
t.Skip("依赖 Step 1")
|
||
}
|
||
// 使用 packageID
|
||
allocationID = extractedID
|
||
})
|
||
|
||
t.Run("Step3_创建订单", func(t *testing.T) {
|
||
if allocationID == 0 {
|
||
t.Skip("依赖 Step 2")
|
||
}
|
||
// 使用 allocationID
|
||
orderID = extractedID
|
||
})
|
||
}
|
||
```
|
||
|
||
### 依赖声明规范
|
||
|
||
每个 step 必须声明依赖:
|
||
|
||
```go
|
||
// ------------------------------------------------------------
|
||
// Step 3: 代理商查看可售套餐
|
||
// 角色: 代理商
|
||
// 调用: GET /api/admin/shop-packages
|
||
// 预期: 列表包含刚分配的套餐
|
||
//
|
||
// 依赖: Step 1 的 packageID, Step 2 的分配操作
|
||
// 破坏点:如果查询不按 shop_id 过滤,代理商会看到其他店铺的套餐
|
||
// ------------------------------------------------------------
|
||
t.Run("Step3_代理商查看可售套餐", func(t *testing.T) {
|
||
if packageID == 0 {
|
||
t.Skip("依赖 Step 1 创建的 packageID")
|
||
}
|
||
// ...
|
||
})
|
||
```
|
||
|
||
## 多角色流程
|
||
|
||
```go
|
||
func TestFlow_CrossRoleWorkflow(t *testing.T) {
|
||
env := testutils.NewIntegrationTestEnv(t)
|
||
|
||
var (
|
||
resourceID uint
|
||
shopID uint = 1
|
||
)
|
||
|
||
// Step 1: 平台管理员创建资源
|
||
t.Run("Step1_平台创建资源", func(t *testing.T) {
|
||
client := env.AsSuperAdmin()
|
||
// ...
|
||
resourceID = extractedID
|
||
})
|
||
|
||
// Step 2: 平台管理员分配给代理商
|
||
t.Run("Step2_分配给代理商", func(t *testing.T) {
|
||
client := env.AsSuperAdmin()
|
||
// ...
|
||
})
|
||
|
||
// Step 3: 代理商查看资源(角色切换!)
|
||
t.Run("Step3_代理商查看", func(t *testing.T) {
|
||
client := env.AsShopAgent(shopID) // 切换到代理商
|
||
// ...
|
||
})
|
||
|
||
// Step 4: 代理商创建订单
|
||
t.Run("Step4_代理商创建订单", func(t *testing.T) {
|
||
client := env.AsShopAgent(shopID)
|
||
// ...
|
||
})
|
||
|
||
// Step 5: 平台管理员查看统计(再次切换)
|
||
t.Run("Step5_平台查看统计", func(t *testing.T) {
|
||
client := env.AsSuperAdmin()
|
||
// ...
|
||
})
|
||
}
|
||
```
|
||
|
||
## 破坏点注释规范
|
||
|
||
流程测试的破坏点更侧重于跨 API 的影响:
|
||
|
||
```go
|
||
// 破坏点:如果套餐创建 API 不返回 ID,后续步骤无法执行
|
||
// 破坏点:如果分配 API 不检查套餐是否存在,可能分配无效套餐
|
||
// 破坏点:如果代理商查询不过滤 shop_id,会看到其他店铺的数据
|
||
// 破坏点:如果订单创建不验证套餐有效期,可能购买过期套餐
|
||
// 破坏点:如果佣金计算不在事务中,可能导致数据不一致
|
||
```
|
||
|
||
## 异常流程测试
|
||
|
||
流程测试也应覆盖异常场景:
|
||
|
||
```go
|
||
func TestFlow_PackageLifecycle_Exceptions(t *testing.T) {
|
||
env := testutils.NewIntegrationTestEnv(t)
|
||
|
||
// ------------------------------------------------------------
|
||
// 异常流程:尝试删除已分配的套餐
|
||
// 预期:删除失败,返回业务错误
|
||
// ------------------------------------------------------------
|
||
t.Run("Exception_删除已分配套餐", func(t *testing.T) {
|
||
// Step 1: 创建套餐
|
||
// Step 2: 分配给店铺
|
||
// Step 3: 尝试删除(预期失败)
|
||
})
|
||
|
||
// ------------------------------------------------------------
|
||
// 异常流程:代理商访问其他店铺的套餐
|
||
// 预期:访问被拒绝
|
||
// ------------------------------------------------------------
|
||
t.Run("Exception_跨店铺访问", func(t *testing.T) {
|
||
// Step 1: 平台创建并分配给店铺 A
|
||
// Step 2: 店铺 B 的代理商尝试访问(预期 403)
|
||
})
|
||
}
|
||
```
|
||
|
||
## 运行测试
|
||
|
||
```bash
|
||
# 运行所有流程测试
|
||
source .env.local && go test -v ./tests/flows/...
|
||
|
||
# 运行特定流程
|
||
source .env.local && go test -v ./tests/flows/... -run TestFlow_PackageLifecycle
|
||
|
||
# 运行特定步骤
|
||
source .env.local && go test -v ./tests/flows/... -run "Step3"
|
||
```
|
||
|
||
## 与 Spec Business Flow 的对应关系
|
||
|
||
```markdown
|
||
# Spec 中的 Business Flow
|
||
|
||
### Flow: 套餐完整生命周期
|
||
|
||
**参与者**: 平台管理员, 代理商
|
||
|
||
**流程步骤**:
|
||
|
||
1. **创建套餐**
|
||
- 角色: 平台管理员
|
||
- 调用: POST /api/admin/packages
|
||
- 预期: 返回套餐 ID
|
||
|
||
2. **分配给代理商**
|
||
- 角色: 平台管理员
|
||
- 调用: POST /api/admin/shop-packages
|
||
- 输入: 套餐 ID + 店铺 ID
|
||
- 预期: 分配成功
|
||
|
||
3. **代理商查看可售套餐**
|
||
- 角色: 代理商
|
||
- 调用: GET /api/admin/shop-packages
|
||
- 预期: 列表包含刚分配的套餐
|
||
```
|
||
|
||
直接转换为测试代码:
|
||
|
||
```go
|
||
func TestFlow_PackageLifecycle(t *testing.T) {
|
||
env := testutils.NewIntegrationTestEnv(t)
|
||
|
||
var packageID uint
|
||
|
||
t.Run("Step1_平台管理员创建套餐", func(t *testing.T) {
|
||
// POST /api/admin/packages
|
||
})
|
||
|
||
t.Run("Step2_分配给代理商", func(t *testing.T) {
|
||
// POST /api/admin/shop-packages
|
||
})
|
||
|
||
t.Run("Step3_代理商查看可售套餐", func(t *testing.T) {
|
||
// GET /api/admin/shop-packages
|
||
})
|
||
}
|
||
```
|
||
|
||
## 完整示例
|
||
|
||
```go
|
||
package flows
|
||
|
||
import (
|
||
"fmt"
|
||
"testing"
|
||
|
||
"github.com/stretchr/testify/assert"
|
||
"github.com/stretchr/testify/require"
|
||
|
||
"junhong_cmp_fiber/tests/testutils"
|
||
)
|
||
|
||
// ============================================================
|
||
// 流程测试:IoT 卡导入到激活完整流程
|
||
// 来源:openspec/changes/iot-card-management/specs/iot-card/spec.md
|
||
// 参与者:平台管理员, 系统
|
||
// ============================================================
|
||
|
||
func TestFlow_IotCardImportActivate(t *testing.T) {
|
||
env := testutils.NewIntegrationTestEnv(t)
|
||
|
||
// 流程级共享状态
|
||
var (
|
||
taskID string
|
||
cardICCIDs []string
|
||
)
|
||
|
||
// ------------------------------------------------------------
|
||
// Step 1: 上传 CSV 文件
|
||
// 角色: 平台管理员
|
||
// 调用: POST /api/admin/iot-cards/import
|
||
// 预期: 返回导入任务 ID
|
||
//
|
||
// 依赖: 无
|
||
// 破坏点:如果文件上传不创建异步任务,后续无法追踪进度
|
||
// ------------------------------------------------------------
|
||
t.Run("Step1_上传CSV文件", func(t *testing.T) {
|
||
client := env.AsSuperAdmin()
|
||
|
||
// 创建测试 CSV 内容
|
||
csvContent := "iccid,msisdn,operator\n" +
|
||
"89860000000000000001,13800000001,中国移动\n" +
|
||
"89860000000000000002,13800000002,中国移动\n"
|
||
|
||
resp, err := client.UploadFile("POST", "/api/admin/iot-cards/import",
|
||
"file", "cards.csv", []byte(csvContent))
|
||
require.NoError(t, err)
|
||
require.Equal(t, 200, resp.StatusCode)
|
||
|
||
var result map[string]interface{}
|
||
err = resp.JSON(&result)
|
||
require.NoError(t, err)
|
||
|
||
data := result["data"].(map[string]interface{})
|
||
taskID = data["task_id"].(string)
|
||
require.NotEmpty(t, taskID, "任务 ID 不能为空")
|
||
})
|
||
|
||
// ------------------------------------------------------------
|
||
// Step 2: 查询导入任务状态
|
||
// 角色: 平台管理员
|
||
// 调用: GET /api/admin/iot-cards/import/{taskID}
|
||
// 预期: 任务状态为 completed,导入成功数量 = 2
|
||
//
|
||
// 依赖: Step 1 的 taskID
|
||
// 破坏点:如果异步任务不更新状态,查询会一直返回 pending
|
||
// ------------------------------------------------------------
|
||
t.Run("Step2_查询导入状态", func(t *testing.T) {
|
||
if taskID == "" {
|
||
t.Skip("依赖 Step 1 创建的 taskID")
|
||
}
|
||
|
||
client := env.AsSuperAdmin()
|
||
|
||
// 轮询等待任务完成(最多等待 30 秒)
|
||
var status string
|
||
for i := 0; i < 30; i++ {
|
||
resp, err := client.Request("GET",
|
||
fmt.Sprintf("/api/admin/iot-cards/import/%s", taskID), nil)
|
||
require.NoError(t, err)
|
||
require.Equal(t, 200, resp.StatusCode)
|
||
|
||
var result map[string]interface{}
|
||
resp.JSON(&result)
|
||
data := result["data"].(map[string]interface{})
|
||
status = data["status"].(string)
|
||
|
||
if status == "completed" || status == "failed" {
|
||
break
|
||
}
|
||
time.Sleep(time.Second)
|
||
}
|
||
|
||
require.Equal(t, "completed", status, "导入任务应该成功完成")
|
||
})
|
||
|
||
// ------------------------------------------------------------
|
||
// Step 3: 验证卡片已入库
|
||
// 角色: 平台管理员
|
||
// 调用: GET /api/admin/iot-cards
|
||
// 预期: 能查询到导入的卡片
|
||
//
|
||
// 依赖: Step 2 确认任务完成
|
||
// 破坏点:如果导入任务不写入数据库,查询不到卡片
|
||
// ------------------------------------------------------------
|
||
t.Run("Step3_验证卡片入库", func(t *testing.T) {
|
||
if taskID == "" {
|
||
t.Skip("依赖前置步骤")
|
||
}
|
||
|
||
client := env.AsSuperAdmin()
|
||
|
||
resp, err := client.Request("GET", "/api/admin/iot-cards", map[string]interface{}{
|
||
"iccid": "89860000000000000001",
|
||
})
|
||
require.NoError(t, err)
|
||
require.Equal(t, 200, resp.StatusCode)
|
||
|
||
var result map[string]interface{}
|
||
resp.JSON(&result)
|
||
data := result["data"].(map[string]interface{})
|
||
list := data["list"].([]interface{})
|
||
|
||
require.Len(t, list, 1, "应该能查询到导入的卡片")
|
||
|
||
card := list[0].(map[string]interface{})
|
||
cardICCIDs = append(cardICCIDs, card["iccid"].(string))
|
||
})
|
||
|
||
// ------------------------------------------------------------
|
||
// Step 4: 激活卡片
|
||
// 角色: 平台管理员
|
||
// 调用: POST /api/admin/iot-cards/{iccid}/activate
|
||
// 预期: 卡片状态变为 active
|
||
//
|
||
// 依赖: Step 3 获取的 cardICCIDs
|
||
// 破坏点:如果激活 API 不调用运营商接口,状态不会真正变化
|
||
// ------------------------------------------------------------
|
||
t.Run("Step4_激活卡片", func(t *testing.T) {
|
||
if len(cardICCIDs) == 0 {
|
||
t.Skip("依赖 Step 3 获取的卡片 ICCID")
|
||
}
|
||
|
||
client := env.AsSuperAdmin()
|
||
|
||
resp, err := client.Request("POST",
|
||
fmt.Sprintf("/api/admin/iot-cards/%s/activate", cardICCIDs[0]), nil)
|
||
require.NoError(t, err)
|
||
require.Equal(t, 200, resp.StatusCode)
|
||
})
|
||
|
||
// ------------------------------------------------------------
|
||
// Step 5: 验证卡片状态
|
||
// 角色: 平台管理员
|
||
// 调用: GET /api/admin/iot-cards/{iccid}
|
||
// 预期: 卡片状态为 active
|
||
//
|
||
// 依赖: Step 4 激活操作
|
||
// 破坏点:如果激活后不更新数据库状态,查询还是旧状态
|
||
// ------------------------------------------------------------
|
||
t.Run("Step5_验证激活状态", func(t *testing.T) {
|
||
if len(cardICCIDs) == 0 {
|
||
t.Skip("依赖前置步骤")
|
||
}
|
||
|
||
client := env.AsSuperAdmin()
|
||
|
||
resp, err := client.Request("GET",
|
||
fmt.Sprintf("/api/admin/iot-cards/%s", cardICCIDs[0]), nil)
|
||
require.NoError(t, err)
|
||
require.Equal(t, 200, resp.StatusCode)
|
||
|
||
var result map[string]interface{}
|
||
resp.JSON(&result)
|
||
data := result["data"].(map[string]interface{})
|
||
|
||
assert.Equal(t, "active", data["status"], "卡片状态应该是 active")
|
||
})
|
||
}
|
||
```
|
||
|
||
## 常见问题
|
||
|
||
### Q: Step 之间必须按顺序执行吗?
|
||
|
||
是的。Go 的 t.Run 保证同一个父测试内的子测试按顺序执行。如果前置 step 失败,后续 step 会因为依赖检查而 skip。
|
||
|
||
### Q: 如何处理异步操作?
|
||
|
||
使用轮询等待:
|
||
|
||
```go
|
||
// 等待异步任务完成
|
||
for i := 0; i < maxRetries; i++ {
|
||
status := checkStatus()
|
||
if status == "completed" {
|
||
break
|
||
}
|
||
time.Sleep(interval)
|
||
}
|
||
```
|
||
|
||
### Q: 流程测试太慢怎么办?
|
||
|
||
1. 使用 `t.Parallel()` 让不同流程并行(注意数据隔离)
|
||
2. 减少 sleep 时间,增加轮询频率
|
||
3. 考虑将部分验证移到验收测试
|