新增测试代码规范、修复响应体断言缺失与魔法数字
- AGENTS.md 新增测试代码专项规范:常量、fixture、触发器、HTTP断言、层边界说明 - TestUpdateAllocationExpiryBaseHTTP 补全 PATCH 响应体字段断言 - 四个集成测试文件以 testIDMask 局部常量替换 0x7fffffff 魔法数字 Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
35
AGENTS.md
35
AGENTS.md
@@ -349,5 +349,40 @@ queueClient.EnqueueTask(ctx, constants.TaskTypeXxx, payloadBytes)
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## 测试代码规范(集成测试)
|
||||||
|
|
||||||
|
以下规则专门适用于 `_test.go` 文件,与生产代码规范有局部差异:
|
||||||
|
|
||||||
|
### 常量与魔法数字
|
||||||
|
|
||||||
|
- 测试文件内的魔法数字(如 `0x7fffffff`)在文件顶部定义**局部常量**即可,不要求进 `pkg/constants/`:
|
||||||
|
```go
|
||||||
|
// 文件顶部,仅限测试内使用
|
||||||
|
const testIDMask = 0x7fffffff
|
||||||
|
```
|
||||||
|
- 跨多个测试文件共用的常量或工具函数才提取到 `internal/testutil/`
|
||||||
|
|
||||||
|
### 测试 Fixture Helper
|
||||||
|
|
||||||
|
- `createXxxCard`、`createXxxPackage` 等 fixture 函数优先放 `internal/testutil/`
|
||||||
|
- 同一 package 内 ≤3 处结构类似的 helper(如 ICCID 前缀不同)不强制提取,差异本身就是测试设计意图
|
||||||
|
- 已在 `testutil` 中提供的能力(`NewPostgresTransaction`、`NewRedisClient`、`StringPointer`)必须复用,禁止本地重复定义
|
||||||
|
|
||||||
|
### 数据库触发器操作
|
||||||
|
|
||||||
|
- `ALTER TABLE ... DISABLE TRIGGER` 只允许在测试事务内使用(事务回滚后触发器状态自动恢复),必须加注释说明原因
|
||||||
|
|
||||||
|
### HTTP 集成测试断言
|
||||||
|
|
||||||
|
- 必须同时断言 **HTTP 响应体**和 **DB 状态**;Spec 承诺返回的字段不能只靠 DB 查询替代
|
||||||
|
- 响应体断言至少验证所有 spec 要求的顶层字段非空或值正确,不必断言每个字段的精确中文名称
|
||||||
|
|
||||||
|
### 层边界说明
|
||||||
|
|
||||||
|
- 旧模块 `internal/service/xxx` 可以继续 import `internal/store/postgres`,这是项目触碰式迁移的既有模式
|
||||||
|
- DDD 层边界禁令(禁止 import Fiber / GORM / Redis)**只对 `internal/domain/` 生效**,service 层不受此约束
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
|
||||||
**详细规范和 OpenSpec 工作流请查看**: `@/openspec/AGENTS.md`
|
**详细规范和 OpenSpec 工作流请查看**: `@/openspec/AGENTS.md`
|
||||||
|
|||||||
@@ -1,5 +1,8 @@
|
|||||||
package admin
|
package admin
|
||||||
|
|
||||||
|
// testIDMask 用于将纳秒时间戳截断为合法的 uint 主键范围,仅限测试用。
|
||||||
|
const testIDMask = 0x7fffffff
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"bytes"
|
"bytes"
|
||||||
"context"
|
"context"
|
||||||
@@ -94,7 +97,34 @@ func TestUpdateAllocationExpiryBaseHTTP(t *testing.T) {
|
|||||||
app := fixture.newApp(constants.UserTypeSuperAdmin, 0)
|
app := fixture.newApp(constants.UserTypeSuperAdmin, 0)
|
||||||
path := "/api/admin/shop-package-allocations/" + strconv.FormatUint(uint64(allocation.ID), 10) + "/expiry-base"
|
path := "/api/admin/shop-package-allocations/" + strconv.FormatUint(uint64(allocation.ID), 10) + "/expiry-base"
|
||||||
|
|
||||||
for _, body := range []string{`{"expiry_base_override":"from_activation"}`, `{"expiry_base_override":"from_activation"}`, `{"expiry_base_override":null}`} {
|
// 首次 PATCH 验证响应体包含 spec 要求的所有生效条件字段
|
||||||
|
{
|
||||||
|
status, respBody := expiryBaseHTTPRequest(t, app, http.MethodPatch, path, `{"expiry_base_override":"from_activation"}`)
|
||||||
|
if status != http.StatusOK {
|
||||||
|
t.Fatalf("首次 PATCH 失败:status=%d body=%s", status, respBody)
|
||||||
|
}
|
||||||
|
var resp struct {
|
||||||
|
Data struct {
|
||||||
|
DefaultExpiryBase string `json:"default_expiry_base"`
|
||||||
|
DefaultExpiryBaseName string `json:"default_expiry_base_name"`
|
||||||
|
ExpiryBaseOverride *string `json:"expiry_base_override"`
|
||||||
|
ExpiryBaseOverrideName string `json:"expiry_base_override_name"`
|
||||||
|
EffectiveExpiryBase string `json:"effective_expiry_base"`
|
||||||
|
EffectiveExpiryBaseName string `json:"effective_expiry_base_name"`
|
||||||
|
} `json:"data"`
|
||||||
|
}
|
||||||
|
if err := sonic.Unmarshal(respBody, &resp); err != nil {
|
||||||
|
t.Fatalf("解析 PATCH 响应失败:%v", err)
|
||||||
|
}
|
||||||
|
d := resp.Data
|
||||||
|
if d.DefaultExpiryBase == "" || d.DefaultExpiryBaseName == "" ||
|
||||||
|
d.ExpiryBaseOverride == nil || d.ExpiryBaseOverrideName == "" ||
|
||||||
|
d.EffectiveExpiryBase == "" || d.EffectiveExpiryBaseName == "" {
|
||||||
|
t.Fatalf("PATCH 响应缺少 spec 要求的生效条件字段:%+v", d)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// 幂等 + 恢复默认
|
||||||
|
for _, body := range []string{`{"expiry_base_override":"from_activation"}`, `{"expiry_base_override":null}`} {
|
||||||
status, responseBody := expiryBaseHTTPRequest(t, app, http.MethodPatch, path, body)
|
status, responseBody := expiryBaseHTTPRequest(t, app, http.MethodPatch, path, body)
|
||||||
if status != http.StatusOK {
|
if status != http.StatusOK {
|
||||||
t.Fatalf("修改覆盖值失败:status=%d body=%s", status, responseBody)
|
t.Fatalf("修改覆盖值失败:status=%d body=%s", status, responseBody)
|
||||||
@@ -200,7 +230,7 @@ func expiryBaseHTTPRequest(t *testing.T, app *fiber.App, method, path, body stri
|
|||||||
}
|
}
|
||||||
|
|
||||||
func completeExpiryBaseTestUsage(packageID uint) *model.PackageUsage {
|
func completeExpiryBaseTestUsage(packageID uint) *model.PackageUsage {
|
||||||
unique := uint(time.Now().UnixNano() & 0x7fffffff)
|
unique := uint(time.Now().UnixNano() & testIDMask)
|
||||||
return &model.PackageUsage{OrderID: unique, OrderNo: "UR55-PATCH-USAGE", PackageID: packageID, UsageType: constants.AssetWalletResourceTypeIotCard, IotCardID: unique, DataLimitMB: 1, Status: constants.PackageUsageStatusPending, Priority: 1, PackageName: "UR55测试套餐", Generation: 1, ExpiryBaseSnapshot: constants.PackageExpiryBaseFromPurchase, CalendarTypeSnapshot: constants.PackageCalendarTypeByDay, DurationDaysSnapshot: 30}
|
return &model.PackageUsage{OrderID: unique, OrderNo: "UR55-PATCH-USAGE", PackageID: packageID, UsageType: constants.AssetWalletResourceTypeIotCard, IotCardID: unique, DataLimitMB: 1, Status: constants.PackageUsageStatusPending, Priority: 1, PackageName: "UR55测试套餐", Generation: 1, ExpiryBaseSnapshot: constants.PackageExpiryBaseFromPurchase, CalendarTypeSnapshot: constants.PackageCalendarTypeByDay, DurationDaysSnapshot: 30}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,8 @@
|
|||||||
package order
|
package order
|
||||||
|
|
||||||
|
// testIDMask 用于将纳秒时间戳截断为合法的 uint 主键范围,仅限测试用。
|
||||||
|
const testIDMask = testIDMask
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"strconv"
|
"strconv"
|
||||||
@@ -20,7 +23,7 @@ func TestSynchronousPurchasePersistsImmutableTermsSnapshots(t *testing.T) {
|
|||||||
card := createOrderTermsCard(t, tx, constants.RealNameStatusNotVerified)
|
card := createOrderTermsCard(t, tx, constants.RealNameStatusNotVerified)
|
||||||
formal := createOrderTermsPackage(t, tx, constants.PackageTypeFormal, constants.PackageExpiryBaseFromActivation, 45)
|
formal := createOrderTermsPackage(t, tx, constants.PackageTypeFormal, constants.PackageExpiryBaseFromActivation, 45)
|
||||||
addon := createOrderTermsPackage(t, tx, constants.PackageTypeAddon, constants.PackageExpiryBaseFromActivation, 10)
|
addon := createOrderTermsPackage(t, tx, constants.PackageTypeAddon, constants.PackageExpiryBaseFromActivation, 10)
|
||||||
shopID := uint(time.Now().UnixNano() & 0x7fffffff)
|
shopID := uint(time.Now().UnixNano() & testIDMask)
|
||||||
override := constants.PackageExpiryBaseFromPurchase
|
override := constants.PackageExpiryBaseFromPurchase
|
||||||
for _, packageID := range []uint{formal.ID, addon.ID} {
|
for _, packageID := range []uint{formal.ID, addon.ID} {
|
||||||
allocation := &model.ShopPackageAllocation{ShopID: shopID, PackageID: packageID, CostPrice: 1, RetailPrice: 2, Status: constants.StatusEnabled, ShelfStatus: 1, ExpiryBaseOverride: &override}
|
allocation := &model.ShopPackageAllocation{ShopID: shopID, PackageID: packageID, CostPrice: 1, RetailPrice: 2, Status: constants.StatusEnabled, ShelfStatus: 1, ExpiryBaseOverride: &override}
|
||||||
@@ -96,7 +99,7 @@ func TestCEndPurchasePersistsSnapshotAndActivatesImmediately(t *testing.T) {
|
|||||||
service := &Service{shopPackageAllocationStore: postgres.NewShopPackageAllocationStore(tx), logger: zap.NewNop()}
|
service := &Service{shopPackageAllocationStore: postgres.NewShopPackageAllocationStore(tx), logger: zap.NewNop()}
|
||||||
now := time.Now()
|
now := time.Now()
|
||||||
order := &model.Order{
|
order := &model.Order{
|
||||||
Model: gorm.Model{ID: uint(time.Now().UnixNano() & 0x7fffffff)},
|
Model: gorm.Model{ID: uint(time.Now().UnixNano() & testIDMask)},
|
||||||
OrderNo: "UR55-CEND-" + strconv.FormatInt(time.Now().UnixNano(), 10),
|
OrderNo: "UR55-CEND-" + strconv.FormatInt(time.Now().UnixNano(), 10),
|
||||||
OrderType: model.OrderTypeSingleCard,
|
OrderType: model.OrderTypeSingleCard,
|
||||||
BuyerType: model.BuyerTypePersonal,
|
BuyerType: model.BuyerTypePersonal,
|
||||||
@@ -168,6 +171,6 @@ func createOrderTermsPackage(t *testing.T, tx *gorm.DB, packageType, expiryBase
|
|||||||
}
|
}
|
||||||
|
|
||||||
func newOrderTermsOrder(packageID, cardID uint, sellerShopID *uint) *model.Order {
|
func newOrderTermsOrder(packageID, cardID uint, sellerShopID *uint) *model.Order {
|
||||||
orderID := uint(time.Now().UnixNano() & 0x7fffffff)
|
orderID := uint(time.Now().UnixNano() & testIDMask)
|
||||||
return &model.Order{Model: gorm.Model{ID: orderID}, OrderNo: "UR55-ORDER-" + strconv.FormatUint(uint64(orderID), 10), OrderType: model.OrderTypeSingleCard, BuyerType: model.BuyerTypeAgent, IotCardID: &cardID, SellerShopID: sellerShopID, TotalAmount: 100, Generation: 1}
|
return &model.Order{Model: gorm.Model{ID: orderID}, OrderNo: "UR55-ORDER-" + strconv.FormatUint(uint64(orderID), 10), OrderType: model.OrderTypeSingleCard, BuyerType: model.BuyerTypeAgent, IotCardID: &cardID, SellerShopID: sellerShopID, TotalAmount: 100, Generation: 1}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,8 @@
|
|||||||
package packagepkg
|
package packagepkg
|
||||||
|
|
||||||
|
// testIDMask 用于将纳秒时间戳截断为合法的 uint 主键范围,仅限测试用。
|
||||||
|
const testIDMask = testIDMask
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"strconv"
|
"strconv"
|
||||||
@@ -132,7 +135,7 @@ func TestHistoricalFallbackFiresWarningAndIncrementsCounter(t *testing.T) {
|
|||||||
card := createActivationTermsCard(t, tx, constants.RealNameStatusVerified)
|
card := createActivationTermsCard(t, tx, constants.RealNameStatusVerified)
|
||||||
pkg := createActivationTermsPackage(t, tx, constants.PackageExpiryBaseFromActivation, 21)
|
pkg := createActivationTermsPackage(t, tx, constants.PackageExpiryBaseFromActivation, 21)
|
||||||
// 历史记录:四个快照字段全为空值/零值
|
// 历史记录:四个快照字段全为空值/零值
|
||||||
unique := uint(time.Now().UnixNano() & 0x7fffffff)
|
unique := uint(time.Now().UnixNano() & testIDMask)
|
||||||
usage := &model.PackageUsage{
|
usage := &model.PackageUsage{
|
||||||
OrderID: unique, OrderNo: "UR55-HIST-" + strconv.FormatUint(uint64(unique), 10),
|
OrderID: unique, OrderNo: "UR55-HIST-" + strconv.FormatUint(uint64(unique), 10),
|
||||||
PackageID: pkg.ID, PackageName: "UR55历史测试套餐",
|
PackageID: pkg.ID, PackageName: "UR55历史测试套餐",
|
||||||
@@ -214,7 +217,7 @@ func createActivationTermsPackage(t *testing.T, tx *gorm.DB, expiryBase string,
|
|||||||
|
|
||||||
func createActivationTermsUsage(t *testing.T, tx *gorm.DB, packageID, cardID uint, priority int, expiryBase string, durationDays int, pendingRealname bool, createdAt time.Time) *model.PackageUsage {
|
func createActivationTermsUsage(t *testing.T, tx *gorm.DB, packageID, cardID uint, priority int, expiryBase string, durationDays int, pendingRealname bool, createdAt time.Time) *model.PackageUsage {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
unique := uint(time.Now().UnixNano() & 0x7fffffff)
|
unique := uint(time.Now().UnixNano() & testIDMask)
|
||||||
usage := &model.PackageUsage{Model: gorm.Model{CreatedAt: createdAt}, OrderID: unique, OrderNo: "UR55-ACTIVATE-" + strconv.FormatUint(uint64(unique), 10), PackageID: packageID, PackageName: "UR55激活测试套餐", UsageType: constants.AssetWalletResourceTypeIotCard, IotCardID: cardID, DataLimitMB: 1, Status: constants.PackageUsageStatusPending, Priority: priority, PendingRealnameActivation: pendingRealname, Generation: 1, ExpiryBaseSnapshot: expiryBase, CalendarTypeSnapshot: constants.PackageCalendarTypeByDay, DurationDaysSnapshot: durationDays}
|
usage := &model.PackageUsage{Model: gorm.Model{CreatedAt: createdAt}, OrderID: unique, OrderNo: "UR55-ACTIVATE-" + strconv.FormatUint(uint64(unique), 10), PackageID: packageID, PackageName: "UR55激活测试套餐", UsageType: constants.AssetWalletResourceTypeIotCard, IotCardID: cardID, DataLimitMB: 1, Status: constants.PackageUsageStatusPending, Priority: priority, PendingRealnameActivation: pendingRealname, Generation: 1, ExpiryBaseSnapshot: expiryBase, CalendarTypeSnapshot: constants.PackageCalendarTypeByDay, DurationDaysSnapshot: durationDays}
|
||||||
if err := tx.Omit("status", "pending_realname_activation").Create(usage).Error; err != nil {
|
if err := tx.Omit("status", "pending_realname_activation").Create(usage).Error; err != nil {
|
||||||
t.Fatalf("创建激活测试使用记录失败:%v", err)
|
t.Fatalf("创建激活测试使用记录失败:%v", err)
|
||||||
|
|||||||
@@ -1,5 +1,8 @@
|
|||||||
package task
|
package task
|
||||||
|
|
||||||
|
// testIDMask 用于将纳秒时间戳截断为合法的 uint 主键范围,仅限测试用。
|
||||||
|
const testIDMask = testIDMask
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"strconv"
|
"strconv"
|
||||||
@@ -34,7 +37,7 @@ func TestAutoPurchasePersistsTermsSnapshotsAndRealnameDecision(t *testing.T) {
|
|||||||
tx := testutil.NewPostgresTransaction(t)
|
tx := testutil.NewPostgresTransaction(t)
|
||||||
card := createAutoPurchaseCard(t, tx, testCase.realnameStatus)
|
card := createAutoPurchaseCard(t, tx, testCase.realnameStatus)
|
||||||
pkg := createAutoPurchasePackage(t, tx, constants.PackageTypeFormal, testCase.defaultBase, 31)
|
pkg := createAutoPurchasePackage(t, tx, constants.PackageTypeFormal, testCase.defaultBase, 31)
|
||||||
shopID := uint(time.Now().UnixNano() & 0x7fffffff)
|
shopID := uint(time.Now().UnixNano() & testIDMask)
|
||||||
if testCase.override != nil {
|
if testCase.override != nil {
|
||||||
allocation := &model.ShopPackageAllocation{ShopID: shopID, PackageID: pkg.ID, CostPrice: 1, RetailPrice: 2, Status: constants.StatusEnabled, ShelfStatus: 1, ExpiryBaseOverride: testCase.override}
|
allocation := &model.ShopPackageAllocation{ShopID: shopID, PackageID: pkg.ID, CostPrice: 1, RetailPrice: 2, Status: constants.StatusEnabled, ShelfStatus: 1, ExpiryBaseOverride: testCase.override}
|
||||||
if err := tx.Create(allocation).Error; err != nil {
|
if err := tx.Create(allocation).Error; err != nil {
|
||||||
@@ -186,7 +189,7 @@ func createAutoPurchasePackage(t *testing.T, tx *gorm.DB, packageType, expiryBas
|
|||||||
}
|
}
|
||||||
|
|
||||||
func newAutoPurchaseOrder(cardID uint, sellerShopID *uint) *model.Order {
|
func newAutoPurchaseOrder(cardID uint, sellerShopID *uint) *model.Order {
|
||||||
orderID := uint(time.Now().UnixNano() & 0x7fffffff)
|
orderID := uint(time.Now().UnixNano() & testIDMask)
|
||||||
return &model.Order{Model: gorm.Model{ID: orderID}, OrderNo: "UR55-AUTO-" + strconv.FormatUint(uint64(orderID), 10), OrderType: model.OrderTypeSingleCard, IotCardID: &cardID, SellerShopID: sellerShopID, TotalAmount: 100, Generation: 1}
|
return &model.Order{Model: gorm.Model{ID: orderID}, OrderNo: "UR55-AUTO-" + strconv.FormatUint(uint64(orderID), 10), OrderType: model.OrderTypeSingleCard, IotCardID: &cardID, SellerShopID: sellerShopID, TotalAmount: 100, Generation: 1}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user