topfans/backend/services/assetService/provider/asset_provider_test.go
zerosaturation 8a767fb400 fix(backend): service stability — batch 3 accumulated (bcrypt off-txn / login anti-enum / MQ stub / aiChat / event reliability / gateway aggregate)
- 3.1 bcrypt 移出事务 (Register): repository.HashPassword 前移到 db.Transaction 之前。
- 3.2 Login 消除用户枚举 + 限流 + timing 抹平: pkg/errors 加 ErrInvalidCredential / ErrTooManyLoginAttempts;
  user-not-found 跑 dummy bcrypt 抹平 ~100ms 时序差; mobile 5次/ip 20次 per 15min 限流 (Redis, fail-open 降级)。
- 3.3 MQ streams adapter 停用 → stub: 0 业务调用方, noop EventProducer.Publish; Init 不再装配 streams;
  11 处硬编码 'gallery'/'default' 抽常量到 pkg/queue/consts (值不变, 消漂移)。
- 3.5 JWT 密钥治理: pkg/jwt MustInit fail-fast + atomic.Value, 50-goroutine race_test 零告警;
  MustInit 调用点 gateway main + auth_provider + loadgen 同步更新。
- 3.6 aiChat 健壮性: SaveContext 用 persona.ID(非 req.PersonaId); Redis/memory 错误 记 WARN 不静默;
  Dify err 映射稳定用户文案。
- 3.7 statistic.Client 重构: TrackEvent 改 buffered channel (cap 1024) + dispatchLoop worker。
- 3.8 网关聚合: StarCache (60s TTL, singleflight) 替换 GetFanIdentities 链式调用;
  DeleteAccount 改网关直调 userService.DeleteAccount(避免改 hand-written triple.go);
  铸造双写改异步 channel+consumer (3 retry)。
- 大量单测: 各子项 TDD (RED→GREEN), 关键并发 race_test (50 goroutine)。
- .env.example JWT_SECRET 改为 ≥32 字节 base64 示例(原为空, 被 MustInit 立即拒)。

Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-24 14:03:21 +08:00

233 lines
7.5 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 provider
import (
"context"
"os"
"strconv"
"testing"
"time"
"dubbo.apache.org/dubbo-go/v3/common/constant"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"google.golang.org/grpc/codes"
"github.com/topfans/backend/pkg/database"
"github.com/topfans/backend/pkg/models"
pb "github.com/topfans/backend/pkg/proto/asset"
"github.com/topfans/backend/services/assetService/repository"
"github.com/topfans/backend/services/assetService/service"
"gorm.io/gorm"
)
// ---- GetAssetsByType (DB-backed, 自包含) ----
//
// 这些用例验证 starbook 分类查询的过滤语义:
// - type 过滤: 只返回请求类型的分组
// - grade 过滤: regular 类型下按等级筛选
// - category 过滤: 分类维度筛选
// - 未认证: ctx 无可信身份时拒绝
//
// 自包含约定(遵循 asset_level_service_test.go 的范式):
// - TEST_DB_* 覆盖,默认 localhost:15432 / postgres / 123456 / top-fans
// - 连不上则 t.Skip,不污染其它测试
// - 只清理本用例写入的 sentinel owner_uid 行,不做大范围 cleanup
const (
// sentinel 身份: 高位取值避免与既有测试/业务数据碰撞
sbTestOwnerUID = int64(990201)
sbTestStarID = int64(990202)
)
func sbEnvOrDefault(key, def string) string {
if v := os.Getenv(key); v != "" {
return v
}
return def
}
// starbookTestDB 建立 GetAssetsByType 用例专用的 DB 连接。
func starbookTestDB(t *testing.T) *gorm.DB {
t.Helper()
if os.Getenv("SKIP_DB_TESTS") != "" {
t.Skip("SKIP_DB_TESTS set")
}
portStr := sbEnvOrDefault("TEST_DB_PORT", "15432")
port, _ := strconv.Atoi(portStr)
if port == 0 {
port = 15432
}
if err := database.Init(database.Config{
Host: sbEnvOrDefault("TEST_DB_HOST", "localhost"),
Port: port,
User: sbEnvOrDefault("TEST_DB_USER", "postgres"),
Password: sbEnvOrDefault("TEST_DB_PASSWORD", "123456"),
DBName: sbEnvOrDefault("TEST_DB_NAME", "top-fans"),
SSLMode: "disable",
TimeZone: "Asia/Shanghai",
}); err != nil {
t.Skipf("Skipping: cannot connect to test database: %v", err)
}
db := database.GetDB()
if err := db.AutoMigrate(&models.Asset{}, &models.AssetRegistry{}); err != nil {
t.Skipf("Skipping: AutoMigrate failed: %v", err)
}
return db
}
// sbCleanup 只删本用例的 sentinel 行(assets/registry + FK 依赖的 user/star)。
func sbCleanup(t *testing.T, db *gorm.DB) {
t.Helper()
db.Where("owner_uid = ? AND star_id = ?", sbTestOwnerUID, sbTestStarID).Delete(&models.AssetRegistry{})
db.Where("owner_uid = ? AND star_id = ?", sbTestOwnerUID, sbTestStarID).Delete(&models.Asset{})
db.Exec("DELETE FROM stars WHERE star_id = ?", sbTestStarID)
db.Exec("DELETE FROM users WHERE id = ?", sbTestOwnerUID)
}
// sbSeed 写入 sentinel 藏品 + registry:
// - regular grade 1
// - regular grade 3
// - collection (category=手办)
func sbSeed(t *testing.T, db *gorm.DB) {
t.Helper()
sbCleanup(t, db)
now := time.Now().UnixMilli()
// assets FK: owner_uid -> users(id), star_id -> stars(star_id)。
// 显式 id 插入后按 CLAUDE.md 规范同步序列,避免后续 GORM 自增撞主键。
require.NoError(t, db.Exec(
`INSERT INTO users (id, mobile, password_hash, created_at, updated_at)
VALUES (?, ?, 'x', ?, ?) ON CONFLICT (id) DO NOTHING`,
sbTestOwnerUID, "99902010001", now, now).Error)
require.NoError(t, db.Exec(`SELECT setval('users_id_seq', (SELECT MAX(id) FROM users))`).Error)
require.NoError(t, db.Exec(
`INSERT INTO stars (star_id, name, identity_id, created_at, updated_at)
VALUES (?, 'sb_test_star', 'sb_test_identity', ?, ?) ON CONFLICT (star_id) DO NOTHING`,
sbTestStarID, now, now).Error)
require.NoError(t, db.Exec(`SELECT setval('stars_star_id_seq', (SELECT MAX(star_id) FROM stars))`).Error)
newAsset := func(name string) *models.Asset {
a := &models.Asset{
OwnerUID: sbTestOwnerUID,
StarID: sbTestStarID,
Name: name,
CoverURL: "https://cdn/" + name + ".png",
Status: 1,
IsActive: true,
CreatedAt: now,
UpdatedAt: now,
}
require.NoError(t, db.Create(a).Error)
return a
}
grade1 := int32(1)
grade3 := int32(3)
cat := "手办"
regA := newAsset("regular_g1")
regB := newAsset("regular_g3")
colC := newAsset("collection_x")
regs := []*models.AssetRegistry{
{AssetID: regA.ID, AssetType: models.AssetTypeRegular, OwnerUID: sbTestOwnerUID, StarID: sbTestStarID, Grade: &grade1, DisplayStatus: 1},
{AssetID: regB.ID, AssetType: models.AssetTypeRegular, OwnerUID: sbTestOwnerUID, StarID: sbTestStarID, Grade: &grade3, DisplayStatus: 1},
{AssetID: colC.ID, AssetType: models.AssetTypeCollection, OwnerUID: sbTestOwnerUID, StarID: sbTestStarID, CollectionCategory: &cat, DisplayStatus: 1},
}
for _, r := range regs {
require.NoError(t, db.Create(r).Error)
}
t.Cleanup(func() { sbCleanup(t, db) })
}
// sbProvider 组装真实 service真实 repo + test DB无关依赖传 nil。
func sbProvider(db *gorm.DB) *AssetProvider {
assetRepo := repository.NewAssetRepository(db)
registryRepo := repository.NewAssetRegistryRepository(db)
svc := service.NewAssetService(assetRepo, nil, nil, nil, db, registryRepo, nil)
return &AssetProvider{assetService: svc}
}
// sbCtx 构造携带可信身份的 Dubbo attachments ctx。
func sbCtx(userID, starID int64) context.Context {
return context.WithValue(context.Background(), constant.AttachmentKey, map[string]interface{}{
"user_id": userID,
"star_id": starID,
})
}
func TestGetAssetsByType_FilterByTypeRegular(t *testing.T) {
db := starbookTestDB(t)
sbSeed(t, db)
p := sbProvider(db)
resp, err := p.GetAssetsByType(sbCtx(sbTestOwnerUID, sbTestStarID), &pb.GetAssetsByTypeRequest{
Type: models.AssetTypeRegular,
})
require.NoError(t, err)
require.NotNil(t, resp)
require.NotNil(t, resp.Data)
require.Len(t, resp.Data.Groups, 1, "type=regular 只应返回 regular 分组")
assert.Equal(t, models.AssetTypeRegular, resp.Data.Groups[0].Type)
// regular 分组含 grade 1 + grade 3 两个等级
var total int32
for _, g := range resp.Data.Groups[0].Grades {
total += g.TotalCount
}
assert.Equal(t, int32(2), total, "regular 应含两条(grade1+grade3)")
}
func TestGetAssetsByType_FilterByGrade(t *testing.T) {
db := starbookTestDB(t)
sbSeed(t, db)
p := sbProvider(db)
resp, err := p.GetAssetsByType(sbCtx(sbTestOwnerUID, sbTestStarID), &pb.GetAssetsByTypeRequest{
Type: models.AssetTypeRegular,
Grade: 3,
})
require.NoError(t, err)
require.NotNil(t, resp.Data)
require.Len(t, resp.Data.Groups, 1)
grades := resp.Data.Groups[0].Grades
require.Len(t, grades, 1, "grade=3 过滤后只剩一个等级段")
assert.Equal(t, int32(3), grades[0].Grade)
assert.Equal(t, int32(1), grades[0].TotalCount, "grade=3 只有一条")
}
func TestGetAssetsByType_FilterByCategory(t *testing.T) {
db := starbookTestDB(t)
sbSeed(t, db)
p := sbProvider(db)
resp, err := p.GetAssetsByType(sbCtx(sbTestOwnerUID, sbTestStarID), &pb.GetAssetsByTypeRequest{
Type: models.AssetTypeCollection,
Category: "手办",
})
require.NoError(t, err)
require.NotNil(t, resp.Data)
require.Len(t, resp.Data.Groups, 1, "type=collection + category=手办 只返回该分组")
assert.Equal(t, models.AssetTypeCollection, resp.Data.Groups[0].Type)
assert.Equal(t, int32(1), resp.Data.Groups[0].TotalCount)
}
func TestGetAssetsByType_NoIdentity(t *testing.T) {
// 无需 DB: 缺身份必须在触达 service 前拒绝。
p := &AssetProvider{}
resp, err := p.GetAssetsByType(context.Background(), &pb.GetAssetsByTypeRequest{
Type: models.AssetTypeRegular,
})
require.Error(t, err)
if resp != nil && resp.Base != nil {
assert.Equal(t, uint32(codes.Unauthenticated), resp.Base.Code)
}
}