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>
334 lines
11 KiB
Go
334 lines
11 KiB
Go
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 梯度的
|
||
// 典藏藏品(手办分类),用于断言 home(top-3)与 More(全部)的 LikeCount 排序契约。
|
||
// - collection_x(sbSeed 写入) : 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-3(HasMore=true),More 返回全部 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)
|
||
}
|