topfans/backend/gateway/controller/peripheral_controller_test.go
2026-07-13 11:22:56 +08:00

433 lines
16 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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")
// 设置 verify_count=3(测试期望值)
if err := db.Model(&asset).Update("verify_count", 3).Error; err != nil {
t.Fatalf("Failed to set verify_count: %v", err)
}
// peripheral_info raw SQL INSERT(避开任何潜在 GORM 钩子)
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)
}
if resp.Data.VerifyCount != 3 {
t.Errorf("expected verify_count=3, 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.MintFromPeripheralByCode(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.MintFromPeripheralByCode(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.MintFromPeripheralByCode(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.MintFromPeripheralByCode(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)
}
}