From d5a35f6360ddf693c0fd03b44efaba6acdd186b9 Mon Sep 17 00:00:00 2001 From: break Date: Thu, 23 Jul 2026 11:35:38 +0900 Subject: [PATCH] =?UTF-8?q?=E6=96=B0=E5=A2=9E=E6=B5=8B=E8=AF=95=E4=BB=A3?= =?UTF-8?q?=E7=A0=81=E8=A7=84=E8=8C=83=E3=80=81=E4=BF=AE=E5=A4=8D=E5=93=8D?= =?UTF-8?q?=E5=BA=94=E4=BD=93=E6=96=AD=E8=A8=80=E7=BC=BA=E5=A4=B1=E4=B8=8E?= =?UTF-8?q?=E9=AD=94=E6=B3=95=E6=95=B0=E5=AD=97?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - AGENTS.md 新增测试代码专项规范:常量、fixture、触发器、HTTP断言、层边界说明 - TestUpdateAllocationExpiryBaseHTTP 补全 PATCH 响应体字段断言 - 四个集成测试文件以 testIDMask 局部常量替换 0x7fffffff 魔法数字 Co-Authored-By: Claude Sonnet 4.6 --- AGENTS.md | 35 +++++++++++++++++++ ...op_package_expiry_base_integration_test.go | 34 ++++++++++++++++-- .../order/package_terms_integration_test.go | 9 +++-- .../activation_terms_integration_test.go | 7 ++-- .../auto_purchase_terms_integration_test.go | 7 ++-- 5 files changed, 83 insertions(+), 9 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index c0bbc5c..a5d480a 100644 --- a/AGENTS.md +++ b/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` diff --git a/internal/handler/admin/shop_package_expiry_base_integration_test.go b/internal/handler/admin/shop_package_expiry_base_integration_test.go index 89a1cc2..c15149a 100644 --- a/internal/handler/admin/shop_package_expiry_base_integration_test.go +++ b/internal/handler/admin/shop_package_expiry_base_integration_test.go @@ -1,5 +1,8 @@ package admin +// testIDMask 用于将纳秒时间戳截断为合法的 uint 主键范围,仅限测试用。 +const testIDMask = 0x7fffffff + import ( "bytes" "context" @@ -94,7 +97,34 @@ func TestUpdateAllocationExpiryBaseHTTP(t *testing.T) { app := fixture.newApp(constants.UserTypeSuperAdmin, 0) 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) if status != http.StatusOK { 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 { - 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} } diff --git a/internal/service/order/package_terms_integration_test.go b/internal/service/order/package_terms_integration_test.go index d1cc15f..9230dd4 100644 --- a/internal/service/order/package_terms_integration_test.go +++ b/internal/service/order/package_terms_integration_test.go @@ -1,5 +1,8 @@ package order +// testIDMask 用于将纳秒时间戳截断为合法的 uint 主键范围,仅限测试用。 +const testIDMask = testIDMask + import ( "context" "strconv" @@ -20,7 +23,7 @@ func TestSynchronousPurchasePersistsImmutableTermsSnapshots(t *testing.T) { card := createOrderTermsCard(t, tx, constants.RealNameStatusNotVerified) formal := createOrderTermsPackage(t, tx, constants.PackageTypeFormal, constants.PackageExpiryBaseFromActivation, 45) addon := createOrderTermsPackage(t, tx, constants.PackageTypeAddon, constants.PackageExpiryBaseFromActivation, 10) - shopID := uint(time.Now().UnixNano() & 0x7fffffff) + shopID := uint(time.Now().UnixNano() & testIDMask) override := constants.PackageExpiryBaseFromPurchase 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} @@ -96,7 +99,7 @@ func TestCEndPurchasePersistsSnapshotAndActivatesImmediately(t *testing.T) { service := &Service{shopPackageAllocationStore: postgres.NewShopPackageAllocationStore(tx), logger: zap.NewNop()} now := time.Now() 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), OrderType: model.OrderTypeSingleCard, 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 { - 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} } diff --git a/internal/service/package/activation_terms_integration_test.go b/internal/service/package/activation_terms_integration_test.go index dc73619..6f5a339 100644 --- a/internal/service/package/activation_terms_integration_test.go +++ b/internal/service/package/activation_terms_integration_test.go @@ -1,5 +1,8 @@ package packagepkg +// testIDMask 用于将纳秒时间戳截断为合法的 uint 主键范围,仅限测试用。 +const testIDMask = testIDMask + import ( "context" "strconv" @@ -132,7 +135,7 @@ func TestHistoricalFallbackFiresWarningAndIncrementsCounter(t *testing.T) { card := createActivationTermsCard(t, tx, constants.RealNameStatusVerified) pkg := createActivationTermsPackage(t, tx, constants.PackageExpiryBaseFromActivation, 21) // 历史记录:四个快照字段全为空值/零值 - unique := uint(time.Now().UnixNano() & 0x7fffffff) + unique := uint(time.Now().UnixNano() & testIDMask) usage := &model.PackageUsage{ OrderID: unique, OrderNo: "UR55-HIST-" + strconv.FormatUint(uint64(unique), 10), 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 { 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} if err := tx.Omit("status", "pending_realname_activation").Create(usage).Error; err != nil { t.Fatalf("创建激活测试使用记录失败:%v", err) diff --git a/internal/task/auto_purchase_terms_integration_test.go b/internal/task/auto_purchase_terms_integration_test.go index d13bdda..cba910d 100644 --- a/internal/task/auto_purchase_terms_integration_test.go +++ b/internal/task/auto_purchase_terms_integration_test.go @@ -1,5 +1,8 @@ package task +// testIDMask 用于将纳秒时间戳截断为合法的 uint 主键范围,仅限测试用。 +const testIDMask = testIDMask + import ( "context" "strconv" @@ -34,7 +37,7 @@ func TestAutoPurchasePersistsTermsSnapshotsAndRealnameDecision(t *testing.T) { tx := testutil.NewPostgresTransaction(t) card := createAutoPurchaseCard(t, tx, testCase.realnameStatus) 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 { 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 { @@ -186,7 +189,7 @@ func createAutoPurchasePackage(t *testing.T, tx *gorm.DB, packageType, expiryBas } 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} }