topfans/backend/services/assetService/provider/asset_provider_test.go
zerosaturation e079a6c2e2 feat(asset): restore starbook home More button by Asset.LikeCount ordering
starbook 首页每个 grade/group 按 Asset.LikeCount DESC, Asset.ID ASC 取前三张,超三显示 has_more;
GetAssetsByType 不截断返回全部匹配项。包含:
- proto/asset.proto 声明 GetAssetsByType RPC + 消息,重新生成 .pb.go/.triple.go
- gateway starbook controller 改用标准 AssetService 客户端
- assetService: 三个 build*GroupForAssets 增加 previewLimit 参数
- 新增 sortAndLimitAssetItems 共享助手
- 新增 asset_service_group_test.go DB-free 单测
- 新增 TestStarbookHomePreviewAndMoreUseAssetLikeRanking 端到端回归
- frontend pages/starbook/items.vue 适配嵌套 groups/grades/items 响应,单 grade 竖向展示

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-24 19:20:16 +08:00

334 lines
11 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"
"fmt"
"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) })
}
// sbAddRankedCollectionAssets 在 sbSeed 基础上追加一组带 like_count 梯度的
// 典藏藏品(手办分类),用于断言 hometop-3与 More全部的 LikeCount 排序契约。
// - collection_xsbSeed 写入) : like_count = 5
// - collection_ranked_0/1/2追加: like_count = 20/40/30
//
// 预期 home(limit=3): [40, 30, 20];预期 More(limit=0): [40, 30, 20, 5]。
// 注意Asset 记录不显式指定 id交由 PostgreSQL autoIncrement 分配,避免触 CLAUDE.md
// 序列同步规则;新的 AssetRegistry 记录只引用 autoIncrement 出来的 AssetID。
func sbAddRankedCollectionAssets(t *testing.T, db *gorm.DB) {
t.Helper()
var existing models.Asset
require.NoError(t, db.Where(
"owner_uid = ? AND star_id = ? AND name = ?",
sbTestOwnerUID, sbTestStarID, "collection_x",
).First(&existing).Error)
require.NoError(t, db.Model(&existing).Update("like_count", int32(5)).Error)
require.NoError(t, db.Model(&models.AssetRegistry{}).
Where("asset_id = ?", existing.ID).
Update("like_count", int32(999)).Error)
category := "手办"
now := time.Now().UnixMilli()
for index, likeCount := range []int32{20, 40, 30} {
asset := &models.Asset{
OwnerUID: sbTestOwnerUID,
StarID: sbTestStarID,
Name: fmt.Sprintf("collection_ranked_%d", index),
CoverURL: fmt.Sprintf("https://cdn/collection_ranked_%d.png", index),
Status: 1,
IsActive: true,
LikeCount: likeCount,
CreatedAt: now + int64(index+1),
UpdatedAt: now + int64(index+1),
}
require.NoError(t, db.Create(asset).Error)
require.NoError(t, db.Create(&models.AssetRegistry{
AssetID: asset.ID,
AssetType: models.AssetTypeCollection,
OwnerUID: sbTestOwnerUID,
StarID: sbTestStarID,
CollectionCategory: &category,
LikeCount: 1000 - likeCount,
DisplayStatus: 1,
}).Error)
}
}
// requireGroupByType 在 AssetListData.Groups 中按 type 查找目标分组,找不到直接 FailNow。
func requireGroupByType(t *testing.T, groups []*pb.AssetGroup, assetType string) *pb.AssetGroup {
t.Helper()
for _, group := range groups {
if group.Type == assetType {
return group
}
}
t.Fatalf("group type %q not found", assetType)
return nil
}
// itemLikeCounts 提取分组内 AssetItem 的 LikeCount 序列,便于断言排序契约。
func itemLikeCounts(items []*pb.AssetItem) []int32 {
counts := make([]int32, 0, len(items))
for _, item := range items {
counts = append(counts, item.LikeCount)
}
return counts
}
// 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)
}
}
// TestStarbookHomePreviewAndMoreUseAssetLikeRanking 回归 home(limit=3) 与 More(limit=0)
// 的排序与截断契约:两个入口都应按 Asset.LikeCount DESC, Asset.ID ASC 排序,
// home 只返 top-3HasMore=trueMore 返回全部 4 条HasMore=false
// 排序稳定性通过 like_count 全相同时的 ID 次序隐式覆盖。
func TestStarbookHomePreviewAndMoreUseAssetLikeRanking(t *testing.T) {
db := starbookTestDB(t)
sbSeed(t, db)
sbAddRankedCollectionAssets(t, db)
provider := sbProvider(db)
ctx := sbCtx(sbTestOwnerUID, sbTestStarID)
homeResp, err := provider.GetMyAssets(ctx, &pb.GetMyAssetsRequest{})
require.NoError(t, err)
require.NotNil(t, homeResp.Data)
homeGroup := requireGroupByType(t, homeResp.Data.Groups, models.AssetTypeCollection)
assert.Equal(t, []int32{40, 30, 20}, itemLikeCounts(homeGroup.Items))
assert.Equal(t, int32(4), homeGroup.TotalCount)
assert.True(t, homeGroup.HasMore)
moreResp, err := provider.GetAssetsByType(ctx, &pb.GetAssetsByTypeRequest{
Type: models.AssetTypeCollection,
Category: "手办",
})
require.NoError(t, err)
require.NotNil(t, moreResp.Data)
moreGroup := requireGroupByType(t, moreResp.Data.Groups, models.AssetTypeCollection)
assert.Equal(t, []int32{40, 30, 20, 5}, itemLikeCounts(moreGroup.Items))
assert.Equal(t, int32(4), moreGroup.TotalCount)
assert.False(t, moreGroup.HasMore)
}