package controller import ( "encoding/json" "net/http" "net/http/httptest" "strconv" "testing" "github.com/gin-gonic/gin" "gorm.io/gorm" "github.com/topfans/backend/gateway/dto" "github.com/topfans/backend/pkg/database" "github.com/topfans/backend/pkg/models" "github.com/topfans/backend/services/assetService/repository" "github.com/topfans/backend/services/assetService/service" ) // ============================================================ // Test fixture helpers — copied + renamed from service 包 peripheral_test_helpers_test.go // // 原因:service 包 helpers 是 package-private,controller 包无法直接 import; // 共享模式与 service 完全一致 — 用 mobile LIKE '199%' 用户 + identity_id LIKE 'test_ctrl_%' 明星作为测试 fixture, // 由 cleanupControllerTestDB 一并清理。每跑一个测试都会先 cleanup 再 setup,互不污染。 // // ★ 不做硬编码 INSERT user/star/asset (遵守 brief 约束): // // star / user / asset 通过 GORM Create 走模型 BeforeCreate 钩子,自动填时间戳; // peripheral_info 用 raw SQL INSERT (避开任何潜在 GORM 钩子,与 service 包风格一致)。 // // 注意:TestMain 已在 asset_controller_test.go 中声明,统一初始化 gin.TestMode + logger, // 此处不重复。 // ============================================================ func setupControllerTestDB(t *testing.T) *gorm.DB { t.Helper() config := database.Config{ Host: "localhost", Port: 5432, User: "haihuizhu", Password: "admin", DBName: "top-fans", SSLMode: "disable", TimeZone: "Asia/Shanghai", } if err := database.Init(config); err != nil { t.Skipf("Skipping test: failed to connect to test database: %v", err) } db := database.GetDB() if err := db.AutoMigrate(&models.Asset{}, &models.MintOrder{}, &models.AssetLike{}, &models.PeripheralInfo{}); err != nil { t.Logf("Warning: Failed to migrate asset tables (may already exist): %v", err) } // 兼容已有"残缺" stars 表 (与 service helpers 写法完全一致) db.Exec(`CREATE TABLE IF NOT EXISTS stars ( star_id BIGSERIAL PRIMARY KEY, name VARCHAR(100) NOT NULL, tag VARCHAR(100), name_en VARCHAR(500), pic_url VARCHAR(500), description TEXT, identity_id VARCHAR(50) NOT NULL, is_active BOOLEAN NOT NULL DEFAULT true, created_at BIGINT NOT NULL, updated_at BIGINT NOT NULL )`) db.Exec("ALTER TABLE stars ADD COLUMN IF NOT EXISTS tag VARCHAR(100)") db.Exec("ALTER TABLE stars ADD COLUMN IF NOT EXISTS name_en VARCHAR(500)") db.Exec("ALTER TABLE stars ADD COLUMN IF NOT EXISTS pic_url VARCHAR(500)") db.Exec("ALTER TABLE stars ADD COLUMN IF NOT EXISTS description TEXT") db.Exec("CREATE UNIQUE INDEX IF NOT EXISTS uk_stars_identity_id ON stars(identity_id)") cleanupControllerTestDB(t, db) return db } func cleanupControllerTestDB(t *testing.T, db *gorm.DB) { t.Helper() db.Exec("DELETE FROM peripheral_info WHERE asset_id IN (SELECT id FROM assets WHERE owner_uid IN (SELECT id FROM users WHERE mobile LIKE '199%'))") db.Exec("DELETE FROM asset_likes WHERE user_id IN (SELECT id FROM users WHERE mobile LIKE '199%') OR asset_id IN (SELECT id FROM assets WHERE owner_uid IN (SELECT id FROM users WHERE mobile LIKE '199%'))") db.Exec("DELETE FROM assets WHERE owner_uid IN (SELECT id FROM users WHERE mobile LIKE '199%')") db.Exec("DELETE FROM mint_orders WHERE user_id IN (SELECT id FROM users WHERE mobile LIKE '199%')") db.Exec("DELETE FROM fan_profiles WHERE user_id IN (SELECT id FROM users WHERE mobile LIKE '199%')") db.Exec("DELETE FROM users WHERE mobile LIKE '199%'") db.Exec("DELETE FROM stars WHERE identity_id LIKE 'test_ctrl_peripheral_%'") } func createControllerTestStar(t *testing.T, db *gorm.DB, identityID string) *models.Star { t.Helper() var existing models.Star if err := db.Where("identity_id = ?", identityID).First(&existing).Error; err == nil { return &existing } star := &models.Star{ Name: "测试明星-" + identityID, IdentityID: identityID, IsActive: true, } if err := db.Create(star).Error; err != nil { t.Fatalf("Failed to create test star: %v", err) } return star } func createControllerTestUser(t *testing.T, db *gorm.DB, mobile string) *models.User { t.Helper() user := &models.User{ Mobile: mobile, PasswordHash: "test_hash", IsActive: true, } if err := db.Create(user).Error; err != nil { t.Fatalf("Failed to create test user: %v", err) } return user } func createControllerTestAsset(t *testing.T, db *gorm.DB, ownerUID, starID int64, name string) *models.Asset { t.Helper() asset := &models.Asset{ OwnerUID: ownerUID, StarID: starID, Name: name, CoverURL: "https://top-fans-test.oss-cn-shanghai.aliyuncs.com/asset/1/87/1778876360335.jpg", Status: models.AssetStatusActive, IsActive: true, } if err := db.Create(asset).Error; err != nil { t.Fatalf("Failed to create test asset: %v", err) } return asset } // setupVerifiedAsset 准备测试用 asset + peripheral_info // 返回 (assetID, ownerUID, starID) — starID 仅供断言/调试用,核心测试只需 assetID/ownerUID func setupVerifiedAsset(t *testing.T) (int64, int64, int64) { t.Helper() db := setupControllerTestDB(t) star := createControllerTestStar(t, db, "test_ctrl_peripheral_getverify") user := createControllerTestUser(t, db, "19900099801") asset := createControllerTestAsset(t, db, user.ID, star.StarID, "p-ctrl") // peripheral_info raw SQL INSERT(避开任何潜在 GORM 钩子) // verify_count 不显式设置,默认 0,由 GetVerification 调用 IncrementVerifyCount 自增为 1 if err := db.Exec(`INSERT INTO peripheral_info (asset_id, star_id, user_id, code, image, brand, company, hash, verifier, first_verified_at, created_at, updated_at) VALUES (?, 87, 0, 'PERI-2026-001', 'https://top-fans-test.oss-cn-shanghai.aliyuncs.com/asset/1/87/1778876360335.jpg', 'BrandCtrl', 'CompanyCtrl', '0xctrl', 'verifierCtrl', 1715600000000, 1, 1)`, asset.ID).Error; err != nil { t.Fatalf("Failed to insert test peripheral_info: %v", err) } return asset.ID, user.ID, star.StarID } // newTestPeripheralController 创建真实 PeripheralController 实例 // 复用 database 全局 DB(由 setupControllerTestDB 初始化过) func newTestPeripheralController(t *testing.T) *PeripheralController { t.Helper() db := database.GetDB() if db == nil { t.Fatal("database.GetDB() is nil — 调用方需先调用 setupControllerTestDB(t)") } repo := repository.NewPeripheralRepository(db) svc := service.NewPeripheralService(repo) return NewPeripheralController(svc) } // ============================================================ // 5 个 controller 单测 // 覆盖范围:happy path × 2 / 错误码 50003 / 50004 / 50012 / 401 unauthorized // ============================================================ // TestPeripheralController_GetVerification_HappyPath // 验证:asset + peripheral_info 存在 → service 返 VerificationResult → controller 组装 DTO → 200 + code=0 func TestPeripheralController_GetVerification_HappyPath(t *testing.T) { assetID, _, _ := setupVerifiedAsset(t) ctrl := newTestPeripheralController(t) w := httptest.NewRecorder() c, _ := gin.CreateTestContext(w) c.Request = httptest.NewRequest(http.MethodGet, "/api/v1/assets/"+strconv.FormatInt(assetID, 10)+"/verification", nil) c.Params = gin.Params{{Key: "asset_id", Value: strconv.FormatInt(assetID, 10)}} ctrl.GetVerification(c) if w.Code != http.StatusOK { t.Fatalf("expected HTTP 200, got %d, body=%s", w.Code, w.Body.String()) } var resp struct { Code int `json:"code"` Data *dto.VerificationData `json:"data"` Message string `json:"message"` } if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil { t.Fatalf("unmarshal: %v", err) } if resp.Code != 0 { t.Errorf("expected business code=0, got %d (msg=%s)", resp.Code, resp.Message) } if resp.Data == nil { t.Fatalf("expected data populated, got nil") } if resp.Data.AssetID <= 0 { t.Errorf("expected new asset_id > 0, got %d", resp.Data.AssetID) } // verify_count 来自 peripheral_info.verify_count(扫码自增后的实时值) // 测试 fixture 未显式设置 verify_count,默认 0,调用 GetVerification 后自增为 1 if resp.Data.VerifyCount != 1 { t.Errorf("expected verify_count=1 (默认0 + 自增1), got %d", resp.Data.VerifyCount) } if resp.Data.Brand != "BrandCtrl" { t.Errorf("expected brand=BrandCtrl, got %q", resp.Data.Brand) } if resp.Data.Company != "CompanyCtrl" { t.Errorf("expected company=CompanyCtrl, got %q", resp.Data.Company) } if resp.Data.Hash != "0xctrl" { t.Errorf("expected hash=0xctrl, got %q", resp.Data.Hash) } if resp.Data.Verifier != "verifierCtrl" { t.Errorf("expected verifier=verifierCtrl, got %q", resp.Data.Verifier) } if resp.Data.Image != "https://top-fans-test.oss-cn-shanghai.aliyuncs.com/asset/1/87/1778876360335.jpg" { t.Errorf("expected image=https://top-fans-test.oss-cn-shanghai.aliyuncs.com/asset/1/87/1778876360335.jpg, got %q", resp.Data.Image) } // first_verified_at=1715600000000 ms → 1715600000 s if resp.Data.VerifiedAt != 1715600000 { t.Errorf("expected verified_at=1715600000 (seconds), got %d", resp.Data.VerifiedAt) } } // TestPeripheralController_GetVerification_AssetNotFound // 验证:asset_id 不存在 → service 返 BizCodeAssetNotFound(50003) → controller 透传业务码到 body // 注意:response.ErrorWithCode 仍返 HTTP 200,业务码在 body.code func TestPeripheralController_GetVerification_AssetNotFound(t *testing.T) { ctrl := newTestPeripheralController(t) w := httptest.NewRecorder() c, _ := gin.CreateTestContext(w) c.Request = httptest.NewRequest(http.MethodGet, "/api/v1/assets/999999999999/verification", nil) c.Params = gin.Params{{Key: "asset_id", Value: "999999999999"}} ctrl.GetVerification(c) if w.Code != http.StatusOK { t.Fatalf("expected HTTP 200 (response.ErrorWithCode), got %d, body=%s", w.Code, w.Body.String()) } var resp struct { Code int `json:"code"` Message string `json:"message"` } if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil { t.Fatalf("unmarshal: %v", err) } if resp.Code != service.BizCodeAssetNotFound { t.Errorf("expected business code=%d (50003), got %d", service.BizCodeAssetNotFound, resp.Code) } } // TestPeripheralController_Mint_HappyPath // 验证:asset 存在 + 无重复注册 → 200 + code=0 + instance_id > 0 func TestPeripheralController_Mint_HappyPath(t *testing.T) { assetID, ownerUID, _ := setupVerifiedAsset(t) ctrl := newTestPeripheralController(t) w := httptest.NewRecorder() c, _ := gin.CreateTestContext(w) c.Request = httptest.NewRequest(http.MethodPost, "/api/v1/assets/"+strconv.FormatInt(assetID, 10)+"/mint-to-my-collection", nil) c.Params = gin.Params{{Key: "code", Value: "PERI-2026-001"}} c.Set("user_id", ownerUID) ctrl.MintFromPeripheralByHash(c) if w.Code != http.StatusOK { t.Fatalf("expected HTTP 200, got %d, body=%s", w.Code, w.Body.String()) } var resp struct { Code int `json:"code"` Data *dto.MintData `json:"data"` Message string `json:"message"` } if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil { t.Fatalf("unmarshal: %v", err) } if resp.Code != 0 { t.Errorf("expected business code=0, got %d (msg=%s)", resp.Code, resp.Message) } if resp.Data == nil { t.Fatalf("expected data populated, got nil") } if resp.Data.InstanceID <= 0 { t.Errorf("expected instance_id > 0, got %d", resp.Data.InstanceID) } if resp.Data.AssetID <= 0 { t.Errorf("expected new asset_id > 0, got %d", resp.Data.AssetID) } if resp.Data.MintedAt <= 0 { t.Errorf("expected minted_at > 0, got %d", resp.Data.MintedAt) } if resp.Data.CoverImage == "" { t.Errorf("expected non-empty cover_image, got %q", resp.Data.CoverImage) } } // TestPeripheralController_Mint_AlreadyAdded // 验证:同一 owner_uid + asset_id + asset_type=peripheral 已有记录 → 50004 // 走 controller 真实链路:先 seed registry,再 mint func TestPeripheralController_Mint_AlreadyAdded(t *testing.T) { assetID, ownerUID, _ := setupVerifiedAsset(t) db := database.GetDB() // 预先插一条 asset_registry 触发查重 if err := db.Exec(`INSERT INTO asset_registry (owner_uid, asset_id, star_id, asset_type, status, created_at, updated_at) VALUES (?, ?, 1, 'peripheral', 1, 1, 1)`, ownerUID, assetID).Error; err != nil { t.Fatalf("Failed to seed asset_registry: %v", err) } ctrl := newTestPeripheralController(t) w := httptest.NewRecorder() c, _ := gin.CreateTestContext(w) c.Request = httptest.NewRequest(http.MethodPost, "/api/v1/assets/"+strconv.FormatInt(assetID, 10)+"/mint-to-my-collection", nil) c.Params = gin.Params{{Key: "code", Value: "PERI-2026-001"}} c.Set("user_id", ownerUID) ctrl.MintFromPeripheralByHash(c) if w.Code != http.StatusOK { t.Fatalf("expected HTTP 200 (response.ErrorWithCode), got %d, body=%s", w.Code, w.Body.String()) } var resp struct { Code int `json:"code"` Message string `json:"message"` } if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil { t.Fatalf("unmarshal: %v", err) } if resp.Code != service.BizCodeAlreadyAdded { t.Errorf("expected business code=%d (50004), got %d", service.BizCodeAlreadyAdded, resp.Code) } } // TestPeripheralController_Mint_RateLimited // 验证:同一 owner_uid 在 24h 内已有 10 条 peripheral mint → 50012 // // 注意:asset_registry 的 UNIQUE 约束是 (asset_type, asset_id), // 故 10 条记录需 10 个不同的 asset_id。这里复用 setupVerifiedAsset 创建的 main asset, // 视为"第 11 条 mint 尝试"(限频阈值是 10)。 // // ★ 使用专用 range 130000100..130000109,避免与 service 包测试 (90000100..) 或历史残留冲突; // 同时在 setup 入口先 DELETE 一次,防御性清理任何前次跑剩的脏数据。 func TestPeripheralController_Mint_RateLimited(t *testing.T) { assetID, ownerUID, _ := setupVerifiedAsset(t) db := database.GetDB() _ = assetID // 仅用于 mint 调用 // 防御性清理:任何前次跑剩的 130000100..130000109 都要先 DELETE,避免 uk_registry_asset_type_id 冲突 db.Exec("DELETE FROM asset_registry WHERE asset_id BETWEEN 130000100 AND 130000109") now := db.NowFunc().UnixMilli() // 复用 GORM NowFunc 与生产逻辑一致 // 插 10 条最近 mint(限频阈值),给 controller 的 mint 调用预热 for i := 0; i < 10; i++ { fakeID := int64(130000100 + i) if err := db.Exec(`INSERT INTO asset_registry (owner_uid, asset_id, star_id, asset_type, status, created_at, updated_at) VALUES (?, ?, 1, 'peripheral', 1, ?, ?)`, ownerUID, fakeID, now, now).Error; err != nil { t.Fatalf("Failed to seed rate-limit asset_registry row %d: %v", i, err) } } ctrl := newTestPeripheralController(t) w := httptest.NewRecorder() c, _ := gin.CreateTestContext(w) c.Request = httptest.NewRequest(http.MethodPost, "/api/v1/assets/"+strconv.FormatInt(assetID, 10)+"/mint-to-my-collection", nil) c.Params = gin.Params{{Key: "code", Value: "PERI-2026-001"}} c.Set("user_id", ownerUID) ctrl.MintFromPeripheralByHash(c) if w.Code != http.StatusOK { t.Fatalf("expected HTTP 200 (response.ErrorWithCode), got %d, body=%s", w.Code, w.Body.String()) } var resp struct { Code int `json:"code"` Message string `json:"message"` } if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil { t.Fatalf("unmarshal: %v", err) } if resp.Code != service.BizCodeRateLimited { t.Errorf("expected business code=%d (50012), got %d", service.BizCodeRateLimited, resp.Code) } } // TestPeripheralController_Mint_Unauthorized // 验证:c.Get("user_id") 拿不到 → handler 防御性分支 → response.Error(c, 401, ...) // 注意:正常生产环境由 router 中间件拦截,本测试只是验证 handler 防御性兜底 func TestPeripheralController_Mint_Unauthorized(t *testing.T) { assetID, _, _ := setupVerifiedAsset(t) ctrl := newTestPeripheralController(t) w := httptest.NewRecorder() c, _ := gin.CreateTestContext(w) c.Request = httptest.NewRequest(http.MethodPost, "/api/v1/assets/"+strconv.FormatInt(assetID, 10)+"/mint-to-my-collection", nil) c.Params = gin.Params{{Key: "code", Value: "PERI-2026-001"}} // 故意不 Set("user_id") ctrl.MintFromPeripheralByHash(c) if w.Code != http.StatusUnauthorized { t.Fatalf("expected HTTP 401, got %d, body=%s", w.Code, w.Body.String()) } var resp struct { Code int `json:"code"` Message string `json:"message"` } if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil { t.Fatalf("unmarshal: %v", err) } // 401 是 transport error,body.code 也是 401 if resp.Code != http.StatusUnauthorized { t.Errorf("expected body code=401, got %d", resp.Code) } }