Files
junhong_cmp_fiber/tests/flows/README.md
huang b18ecfeb55
All checks were successful
构建并部署到测试环境(无 SSH) / build-and-deploy (push) Successful in 6m29s
refactor: 一次性佣金配置从套餐级别提升到系列级别
主要变更:
- 新增 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)
- 删除过时的单元测试(已被验收测试覆盖)
2026-02-04 14:28:44 +08:00

542 lines
15 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 业务流程测试 (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. 考虑将部分验证移到验收测试