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:
322
tests/acceptance/README.md
Normal file
322
tests/acceptance/README.md
Normal file
@@ -0,0 +1,322 @@
|
||||
# 验收测试 (Acceptance Tests)
|
||||
|
||||
验收测试验证单个 API 的契约:给定输入,期望输出。
|
||||
|
||||
## 核心原则
|
||||
|
||||
1. **来源于 Spec**:每个测试用例对应 Spec 中的一个 Scenario
|
||||
2. **测试先于实现**:在功能实现前生成,预期全部 FAIL
|
||||
3. **契约验证**:验证 API 的输入输出契约,不测试内部实现
|
||||
4. **必须有破坏点**:每个测试必须注释说明什么代码变更会导致失败
|
||||
|
||||
## 目录结构
|
||||
|
||||
```
|
||||
tests/acceptance/
|
||||
├── README.md # 本文件
|
||||
├── account_acceptance_test.go # 账号管理验收测试
|
||||
├── package_acceptance_test.go # 套餐管理验收测试
|
||||
├── shop_package_acceptance_test.go # 店铺套餐分配验收测试
|
||||
└── ...
|
||||
```
|
||||
|
||||
## 测试模板
|
||||
|
||||
```go
|
||||
package acceptance
|
||||
|
||||
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
|
||||
// ============================================================
|
||||
|
||||
func Test{Capability}_Acceptance(t *testing.T) {
|
||||
env := testutils.NewIntegrationTestEnv(t)
|
||||
|
||||
// ------------------------------------------------------------
|
||||
// Scenario: {场景名称}
|
||||
// GIVEN: {前置条件}
|
||||
// WHEN: {触发动作}
|
||||
// THEN: {预期结果}
|
||||
// AND: {额外验证}
|
||||
//
|
||||
// 破坏点:{描述什么代码变更会导致此测试失败}
|
||||
// ------------------------------------------------------------
|
||||
t.Run("Scenario_{场景名称}", func(t *testing.T) {
|
||||
// GIVEN: 设置前置条件
|
||||
client := env.AsSuperAdmin()
|
||||
|
||||
// WHEN: 执行操作
|
||||
body := map[string]interface{}{
|
||||
// 请求体
|
||||
}
|
||||
resp, err := client.Request("POST", "/api/admin/xxx", body)
|
||||
require.NoError(t, err)
|
||||
|
||||
// THEN: 验证结果
|
||||
assert.Equal(t, 200, resp.StatusCode)
|
||||
|
||||
var result map[string]interface{}
|
||||
err = resp.JSON(&result)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, 0, int(result["code"].(float64)))
|
||||
|
||||
// AND: 额外验证(如数据库状态)
|
||||
// ...
|
||||
})
|
||||
}
|
||||
```
|
||||
|
||||
## 测试分类
|
||||
|
||||
### 正常场景 (Happy Path)
|
||||
|
||||
```go
|
||||
t.Run("Scenario_成功创建资源", func(t *testing.T) {
|
||||
// 测试正常流程
|
||||
})
|
||||
```
|
||||
|
||||
### 参数校验
|
||||
|
||||
```go
|
||||
t.Run("Scenario_参数缺失返回400", func(t *testing.T) {
|
||||
// 测试缺少必填参数
|
||||
})
|
||||
|
||||
t.Run("Scenario_参数格式错误返回400", func(t *testing.T) {
|
||||
// 测试参数格式不符合要求
|
||||
})
|
||||
```
|
||||
|
||||
### 权限校验
|
||||
|
||||
```go
|
||||
t.Run("Scenario_无权限返回403", func(t *testing.T) {
|
||||
// 测试权限不足的情况
|
||||
})
|
||||
|
||||
t.Run("Scenario_跨店铺访问返回403", func(t *testing.T) {
|
||||
// 测试越权访问
|
||||
})
|
||||
```
|
||||
|
||||
### 业务规则
|
||||
|
||||
```go
|
||||
t.Run("Scenario_重复创建返回409", func(t *testing.T) {
|
||||
// 测试业务规则冲突
|
||||
})
|
||||
|
||||
t.Run("Scenario_删除已使用资源返回400", func(t *testing.T) {
|
||||
// 测试业务规则限制
|
||||
})
|
||||
```
|
||||
|
||||
## 破坏点注释规范
|
||||
|
||||
每个测试必须包含"破坏点"注释,说明什么代码变更会导致测试失败:
|
||||
|
||||
```go
|
||||
// 破坏点:如果删除 handler.Create 中的 store.Create 调用,此测试将失败
|
||||
// 破坏点:如果移除参数校验中的 name 必填检查,此测试将失败
|
||||
// 破坏点:如果响应不包含创建的资源 ID,此测试将失败
|
||||
// 破坏点:如果删除权限检查中间件,此测试将失败
|
||||
```
|
||||
|
||||
**为什么需要破坏点**:
|
||||
1. 证明测试真正验证了功能
|
||||
2. 帮助理解测试意图
|
||||
3. 重构时快速定位影响
|
||||
|
||||
## Table-Driven 模式
|
||||
|
||||
对于同一 API 的多个场景,使用 table-driven 模式:
|
||||
|
||||
```go
|
||||
func TestPackage_Create_Validation(t *testing.T) {
|
||||
env := testutils.NewIntegrationTestEnv(t)
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
body map[string]interface{}
|
||||
expectedStatus int
|
||||
expectedCode int
|
||||
breakpoint string
|
||||
}{
|
||||
{
|
||||
name: "名称为空",
|
||||
body: map[string]interface{}{
|
||||
"name": "",
|
||||
"price": 9900,
|
||||
},
|
||||
expectedStatus: 400,
|
||||
expectedCode: 4000, // CodeInvalidParam
|
||||
breakpoint: "移除 name 必填校验",
|
||||
},
|
||||
{
|
||||
name: "价格为负",
|
||||
body: map[string]interface{}{
|
||||
"name": "测试套餐",
|
||||
"price": -100,
|
||||
},
|
||||
expectedStatus: 400,
|
||||
expectedCode: 4000,
|
||||
breakpoint: "移除 price >= 0 校验",
|
||||
},
|
||||
{
|
||||
name: "时长为0",
|
||||
body: map[string]interface{}{
|
||||
"name": "测试套餐",
|
||||
"price": 9900,
|
||||
"duration": 0,
|
||||
},
|
||||
expectedStatus: 400,
|
||||
expectedCode: 4000,
|
||||
breakpoint: "移除 duration > 0 校验",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run("Scenario_"+tt.name, func(t *testing.T) {
|
||||
// 破坏点: {tt.breakpoint}
|
||||
client := env.AsSuperAdmin()
|
||||
|
||||
resp, err := client.Request("POST", "/api/admin/packages", tt.body)
|
||||
require.NoError(t, err)
|
||||
|
||||
assert.Equal(t, tt.expectedStatus, resp.StatusCode)
|
||||
|
||||
var result map[string]interface{}
|
||||
err = resp.JSON(&result)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, tt.expectedCode, int(result["code"].(float64)))
|
||||
})
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## 运行测试
|
||||
|
||||
```bash
|
||||
# 运行所有验收测试
|
||||
source .env.local && go test -v ./tests/acceptance/...
|
||||
|
||||
# 运行特定功能的验收测试
|
||||
source .env.local && go test -v ./tests/acceptance/... -run TestPackage
|
||||
|
||||
# 运行特定场景
|
||||
source .env.local && go test -v ./tests/acceptance/... -run "Scenario_成功创建"
|
||||
```
|
||||
|
||||
## 测试环境
|
||||
|
||||
验收测试使用 `IntegrationTestEnv`,提供:
|
||||
|
||||
- **事务隔离**:每个测试在独立事务中运行,自动回滚
|
||||
- **Redis 清理**:测试前自动清理相关 Redis 键
|
||||
- **身份切换**:支持不同角色的请求
|
||||
|
||||
```go
|
||||
env := testutils.NewIntegrationTestEnv(t)
|
||||
|
||||
// 以超级管理员身份请求
|
||||
env.AsSuperAdmin().Request("GET", "/api/admin/xxx", nil)
|
||||
|
||||
// 以平台用户身份请求
|
||||
env.AsPlatformUser(accountID).Request("GET", "/api/admin/xxx", nil)
|
||||
|
||||
// 以代理商身份请求
|
||||
env.AsShopAgent(shopID).Request("GET", "/api/admin/xxx", nil)
|
||||
|
||||
// 以企业用户身份请求
|
||||
env.AsEnterprise(enterpriseID).Request("GET", "/api/admin/xxx", nil)
|
||||
```
|
||||
|
||||
## 与 Spec 的对应关系
|
||||
|
||||
```markdown
|
||||
# Spec 中的 Scenario
|
||||
|
||||
#### Scenario: 成功创建套餐
|
||||
- **GIVEN** 用户已登录且有创建权限
|
||||
- **WHEN** POST /api/admin/packages with valid data
|
||||
- **THEN** 返回 201 和套餐详情
|
||||
- **AND** 数据库中存在该套餐记录
|
||||
```
|
||||
|
||||
对应测试:
|
||||
|
||||
```go
|
||||
// 直接从 Spec Scenario 转换
|
||||
t.Run("Scenario_成功创建套餐", func(t *testing.T) {
|
||||
// GIVEN: 用户已登录且有创建权限
|
||||
client := env.AsSuperAdmin()
|
||||
|
||||
// WHEN: POST /api/admin/packages with valid data
|
||||
resp, err := client.Request("POST", "/api/admin/packages", validBody)
|
||||
|
||||
// THEN: 返回 201 和套餐详情
|
||||
assert.Equal(t, 201, resp.StatusCode)
|
||||
|
||||
// AND: 数据库中存在该套餐记录
|
||||
// 验证数据库状态
|
||||
})
|
||||
```
|
||||
|
||||
## 常见问题
|
||||
|
||||
### Q: 验收测试和集成测试的区别?
|
||||
|
||||
| 方面 | 验收测试 | 集成测试 |
|
||||
|------|---------|---------|
|
||||
| 来源 | Spec Scenario | 开发者编写 |
|
||||
| 目的 | 验证 API 契约 | 验证系统集成 |
|
||||
| 粒度 | 单 API | 可能涉及多 API |
|
||||
| 时机 | 实现前生成 | 实现后编写 |
|
||||
|
||||
### Q: 测试 PASS 了但功能还没实现?
|
||||
|
||||
说明测试写得太弱。检查:
|
||||
1. 是否验证了响应状态码
|
||||
2. 是否验证了响应体结构
|
||||
3. 是否验证了数据库状态变化
|
||||
4. 破坏点是否准确
|
||||
|
||||
### Q: 如何处理需要前置数据的测试?
|
||||
|
||||
在 GIVEN 阶段创建必要的前置数据:
|
||||
|
||||
```go
|
||||
t.Run("Scenario_删除已分配的套餐失败", func(t *testing.T) {
|
||||
// GIVEN: 存在一个已分配给店铺的套餐
|
||||
client := env.AsSuperAdmin()
|
||||
|
||||
// 创建套餐
|
||||
createResp, _ := client.Request("POST", "/api/admin/packages", packageBody)
|
||||
var createResult map[string]interface{}
|
||||
createResp.JSON(&createResult)
|
||||
packageID := uint(createResult["data"].(map[string]interface{})["id"].(float64))
|
||||
|
||||
// 分配给店铺
|
||||
client.Request("POST", "/api/admin/shop-packages", map[string]interface{}{
|
||||
"package_id": packageID,
|
||||
"shop_id": 1,
|
||||
})
|
||||
|
||||
// WHEN: 尝试删除套餐
|
||||
resp, _ := client.Request("DELETE", fmt.Sprintf("/api/admin/packages/%d", packageID), nil)
|
||||
|
||||
// THEN: 返回 400
|
||||
assert.Equal(t, 400, resp.StatusCode)
|
||||
})
|
||||
```
|
||||
Reference in New Issue
Block a user