refactor: 一次性佣金配置从套餐级别提升到系列级别
All checks were successful
构建并部署到测试环境(无 SSH) / build-and-deploy (push) Successful in 6m29s
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) - 删除过时的单元测试(已被验收测试覆盖)
This commit is contained in:
541
tests/flows/README.md
Normal file
541
tests/flows/README.md
Normal file
@@ -0,0 +1,541 @@
|
||||
# 业务流程测试 (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. 考虑将部分验证移到验收测试
|
||||
Reference in New Issue
Block a user