Files
junhong_cmp_fiber/.opencode/skills/openspec-generate-acceptance-tests/SKILL.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

12 KiB
Raw Blame History

name, description, license, compatibility, metadata
name description license compatibility metadata
openspec-generate-acceptance-tests 从 Spec 的 Scenarios 和 Business Flows 自动生成验收测试和流程测试。测试在实现前生成,预期全部 FAIL证明测试有效。 MIT Requires openspec CLI.
author version
junhong 1.0

测试生成 Skill

从 Spec 文档自动生成两类测试:

  1. 验收测试Acceptance Tests从 Scenarios 生成,验证单 API 契约
  2. 流程测试Flow Tests从 Business Flows 生成,验证多 API 业务场景

触发方式

/opsx:gen-tests [change-name]

如果不指定 change-name自动检测当前活跃的 change。


前置条件

  1. Change 必须存在且包含 spec 文件
  2. Spec 必须包含 ## Scenarios 部分
  3. Spec 建议包含 ## Business Flows 部分(如果有跨 API 场景)

检查命令:

openspec list --json
# 确认 change 存在且有 specs

工作流程

Step 1: 读取 Spec 文件

# 读取 change 的所有 spec 文件
cat openspec/changes/<change-name>/specs/<capability>/spec.md

Step 2: 解析 Scenarios

从 Spec 中提取所有 Scenario

#### Scenario: 成功创建套餐
- **GIVEN** 用户已登录且有创建权限
- **WHEN** POST /api/admin/packages with valid data
- **THEN** 返回 201 和套餐详情
- **AND** 数据库中存在该套餐记录

解析为结构:

{
  "name": "成功创建套餐",
  "given": ["用户已登录且有创建权限"],
  "when": {"method": "POST", "path": "/api/admin/packages", "condition": "valid data"},
  "then": ["返回 201 和套餐详情"],
  "and": ["数据库中存在该套餐记录"]
}

Step 3: 解析 Business Flows

从 Spec 中提取 Business Flow

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

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

**流程步骤**:

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

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

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

Step 4: 生成验收测试

输出路径: tests/acceptance/<capability>_acceptance_test.go

package acceptance

import (
	"testing"

	"github.com/stretchr/testify/assert"
	"github.com/stretchr/testify/require"

	"junhong_cmp_fiber/tests/testutils"
)

// ============================================================
// 验收测试:套餐管理
// 来源openspec/changes/package-management/specs/package/spec.md
// ============================================================

func TestPackage_Acceptance(t *testing.T) {
	env := testutils.NewIntegrationTestEnv(t)

	// ------------------------------------------------------------
	// Scenario: 成功创建套餐
	// GIVEN: 用户已登录且有创建权限
	// WHEN: POST /api/admin/packages with valid data
	// THEN: 返回 201 和套餐详情
	// AND: 数据库中存在该套餐记录
	// 
	// 破坏点:如果删除 handler.Create 中的 store.Create 调用,此测试将失败
	// ------------------------------------------------------------
	t.Run("Scenario_成功创建套餐", func(t *testing.T) {
		// GIVEN: 用户已登录且有创建权限
		client := env.AsSuperAdmin()

		// WHEN: POST /api/admin/packages with valid data
		body := map[string]interface{}{
			"name":        "测试套餐",
			"description": "测试描述",
			"price":       9900,
			"duration":    30,
		}
		resp, err := client.Request("POST", "/api/admin/packages", body)
		require.NoError(t, err)

		// THEN: 返回 201 和套餐详情
		assert.Equal(t, 201, resp.StatusCode)
		
		var result map[string]interface{}
		err = resp.JSON(&result)
		require.NoError(t, err)
		assert.Equal(t, 0, int(result["code"].(float64)))
		
		data := result["data"].(map[string]interface{})
		packageID := uint(data["id"].(float64))
		assert.NotZero(t, packageID)

		// AND: 数据库中存在该套餐记录
		// TODO: 实现后取消注释
		// pkg, err := env.DB().Package.FindByID(ctx, packageID)
		// require.NoError(t, err)
		// assert.Equal(t, "测试套餐", pkg.Name)
	})

	// ------------------------------------------------------------
	// Scenario: 创建套餐参数校验失败
	// GIVEN: 用户已登录
	// WHEN: POST /api/admin/packages with invalid data (name empty)
	// THEN: 返回 400 和错误信息
	// 
	// 破坏点:如果删除 handler 中的参数校验,此测试将失败
	// ------------------------------------------------------------
	t.Run("Scenario_创建套餐参数校验失败", func(t *testing.T) {
		// GIVEN: 用户已登录
		client := env.AsSuperAdmin()

		// WHEN: POST /api/admin/packages with invalid data
		body := map[string]interface{}{
			"name":  "", // 空名称
			"price": -1, // 负价格
		}
		resp, err := client.Request("POST", "/api/admin/packages", body)
		require.NoError(t, err)

		// THEN: 返回 400 和错误信息
		assert.Equal(t, 400, resp.StatusCode)
	})
}

Step 5: 生成流程测试

输出路径: tests/flows/<capability>_<flow-name>_flow_test.go

package flows

import (
	"testing"

	"github.com/stretchr/testify/assert"
	"github.com/stretchr/testify/require"

	"junhong_cmp_fiber/tests/testutils"
)

// ============================================================
// 流程测试:套餐完整生命周期
// 来源openspec/changes/package-management/specs/package/spec.md
// 参与者:平台管理员, 代理商
// ============================================================

func TestFlow_PackageLifecycle(t *testing.T) {
	env := testutils.NewIntegrationTestEnv(t)

	// 流程级共享状态
	var (
		packageID uint
		shopID    uint = 1 // 测试店铺 ID
	)

	// ------------------------------------------------------------
	// Step 1: 创建套餐
	// 角色: 平台管理员
	// 调用: POST /api/admin/packages
	// 预期: 返回套餐 ID
	// 
	// 破坏点:如果套餐创建 API 不返回 ID后续步骤无法执行
	// ------------------------------------------------------------
	t.Run("Step1_平台管理员创建套餐", func(t *testing.T) {
		client := env.AsSuperAdmin()

		body := map[string]interface{}{
			"name":        "流程测试套餐",
			"description": "用于流程测试",
			"price":       19900,
			"duration":    30,
		}
		resp, err := client.Request("POST", "/api/admin/packages", body)
		require.NoError(t, err)
		require.Equal(t, 201, resp.StatusCode)

		var result map[string]interface{}
		err = resp.JSON(&result)
		require.NoError(t, err)

		data := result["data"].(map[string]interface{})
		packageID = uint(data["id"].(float64))
		require.NotZero(t, packageID, "套餐 ID 不能为空")
	})

	// ------------------------------------------------------------
	// Step 2: 分配给代理商
	// 角色: 平台管理员
	// 调用: POST /api/admin/shop-packages
	// 输入: 套餐 ID + 店铺 ID
	// 预期: 分配成功
	// 
	// 依赖: Step 1 的 packageID
	// 破坏点:如果分配 API 不检查套餐是否存在,可能分配无效套餐
	// ------------------------------------------------------------
	t.Run("Step2_分配套餐给代理商", func(t *testing.T) {
		if packageID == 0 {
			t.Skip("依赖 Step 1 创建的 packageID")
		}

		client := env.AsSuperAdmin()

		body := map[string]interface{}{
			"package_id": packageID,
			"shop_id":    shopID,
		}
		resp, err := client.Request("POST", "/api/admin/shop-packages", body)
		require.NoError(t, err)
		require.Equal(t, 200, resp.StatusCode)
	})

	// ------------------------------------------------------------
	// Step 3: 代理商查看可售套餐
	// 角色: 代理商
	// 调用: GET /api/admin/shop-packages
	// 预期: 列表包含刚分配的套餐
	// 
	// 依赖: Step 2 的分配操作
	// 破坏点:如果查询不按店铺过滤,代理商会看到其他店铺的套餐
	// ------------------------------------------------------------
	t.Run("Step3_代理商查看可售套餐", func(t *testing.T) {
		if packageID == 0 {
			t.Skip("依赖 Step 1 创建的 packageID")
		}

		// 以代理商身份请求
		client := env.AsShopAgent(shopID)

		resp, err := client.Request("GET", "/api/admin/shop-packages", nil)
		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{})
		list := data["list"].([]interface{})
		
		found := false
		for _, item := range list {
			pkg := item.(map[string]interface{})
			if uint(pkg["package_id"].(float64)) == packageID {
				found = true
				break
			}
		}
		assert.True(t, found, "代理商应该能看到刚分配的套餐")
	})
}

Step 6: 运行测试验证

生成测试后,立即运行验证:

# 预期全部 FAIL因为功能尚未实现
source .env.local && go test -v ./tests/acceptance/... ./tests/flows/... 2>&1 | head -50

如果测试 PASS

  • 说明测试写得太弱,没有真正验证功能
  • 需要加强测试或检查是否功能已存在

测试模板规范

验收测试必须包含

  1. 来源注释:标明从哪个 spec 文件生成
  2. Scenario 注释:完整的 GIVEN/WHEN/THEN/AND
  3. 破坏点注释:说明什么代码变更会导致测试失败
  4. 清晰的结构GIVEN → WHEN → THEN → AND 分块

流程测试必须包含

  1. 来源注释:标明从哪个 spec 文件生成
  2. 参与者注释:涉及哪些角色
  3. 共享状态声明:流程中需要传递的数据
  4. 依赖声明:每个 step 依赖哪些前置 step
  5. 破坏点注释:说明什么代码变更会导致测试失败

破坏点注释示例

// 破坏点:如果删除 handler.Create 中的 store.Create 调用,此测试将失败
// 破坏点:如果移除参数校验中的 name 必填检查,此测试将失败
// 破坏点:如果查询不按 shop_id 过滤,此测试将失败(会返回其他店铺数据)
// 破坏点:如果状态机允许 "已使用" → "未分配" 的转换,此测试将失败

输出文件

来源 输出
Spec Scenarios tests/acceptance/<capability>_acceptance_test.go
Spec Business Flows tests/flows/<capability>_<flow>_flow_test.go

Guardrails

  • 不要跳过 Scenario - 每个 Scenario 必须对应一个测试用例
  • 不要省略破坏点 - 每个测试必须有破坏点注释
  • 不要硬编码 ID - 使用共享变量传递流程中的 ID
  • 不要忽略依赖 - 流程测试的 step 必须声明依赖
  • 不要写永远 PASS 的测试 - 测试必须在功能缺失时 FAIL

与其他 Skills 的关系

Skill 关系
openspec-continue-change 生成 spec 后可触发 gen-tests
openspec-apply-change 实现时参考生成的测试
openspec-verify-change 验证时运行生成的测试

示例交互

User: /opsx:gen-tests package-management

AI: 正在分析 change: package-management

找到 spec 文件: openspec/changes/package-management/specs/package/spec.md

解析结果:
- Scenarios: 5 个
- Business Flows: 2 个

生成测试文件:

1. tests/acceptance/package_acceptance_test.go
   - Scenario_成功创建套餐
   - Scenario_创建套餐参数校验失败
   - Scenario_成功更新套餐
   - Scenario_成功删除套餐
   - Scenario_删除已分配套餐失败

2. tests/flows/package_lifecycle_flow_test.go
   - Step1_平台管理员创建套餐
   - Step2_分配套餐给代理商
   - Step3_代理商查看可售套餐
   - Step4_代理商创建订单
   - Step5_套餐使用统计更新

3. tests/flows/package_allocation_flow_test.go
   - Step1_批量分配套餐
   - Step2_验证分配结果
   - Step3_撤销部分分配

运行验证:
$ source .env.local && go test -v ./tests/acceptance/package_... ./tests/flows/package_...

--- FAIL: TestPackage_Acceptance (0.00s)
    --- FAIL: TestPackage_Acceptance/Scenario_成功创建套餐 (0.00s)
        package_acceptance_test.go:45: 404 != 201
--- FAIL: TestFlow_PackageLifecycle (0.00s)
    --- FAIL: TestFlow_PackageLifecycle/Step1_平台管理员创建套餐 (0.00s)
        package_lifecycle_flow_test.go:38: 404 != 201

✓ 所有测试预期 FAIL功能未实现
✓ 测试生成完成

下一步: /opsx:continue 生成 design 和 tasks