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

15 KiB
Raw Blame History

业务流程测试 (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 卡导入激活流程
└── ...

测试模板

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 契约 验证业务场景

状态共享模式

使用包级变量

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 必须声明依赖:

// ------------------------------------------------------------
// 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")
	}
	// ...
})

多角色流程

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 的影响:

// 破坏点:如果套餐创建 API 不返回 ID后续步骤无法执行
// 破坏点:如果分配 API 不检查套餐是否存在,可能分配无效套餐
// 破坏点:如果代理商查询不过滤 shop_id会看到其他店铺的数据
// 破坏点:如果订单创建不验证套餐有效期,可能购买过期套餐
// 破坏点:如果佣金计算不在事务中,可能导致数据不一致

异常流程测试

流程测试也应覆盖异常场景:

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
	})
}

运行测试

# 运行所有流程测试
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 的对应关系

# Spec 中的 Business Flow

### Flow: 套餐完整生命周期

**参与者**: 平台管理员, 代理商

**流程步骤**:

1. **创建套餐**
   - 角色: 平台管理员
   - 调用: POST /api/admin/packages
   - 预期: 返回套餐 ID

2. **分配给代理商**
   - 角色: 平台管理员
   - 调用: POST /api/admin/shop-packages
   - 输入: 套餐 ID + 店铺 ID
   - 预期: 分配成功

3. **代理商查看可售套餐**
   - 角色: 代理商
   - 调用: GET /api/admin/shop-packages
   - 预期: 列表包含刚分配的套餐

直接转换为测试代码:

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
	})
}

完整示例

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: 如何处理异步操作?

使用轮询等待:

// 等待异步任务完成
for i := 0; i < maxRetries; i++ {
	status := checkStatus()
	if status == "completed" {
		break
	}
	time.Sleep(interval)
}

Q: 流程测试太慢怎么办?

  1. 使用 t.Parallel() 让不同流程并行(注意数据隔离)
  2. 减少 sleep 时间,增加轮询频率
  3. 考虑将部分验证移到验收测试