433 lines
16 KiB
Go
433 lines
16 KiB
Go
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)
|
||
}
|
||
}
|