topfans/backend/gateway/controller/starbook_controller_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

273 lines
9.3 KiB
Go

package controller
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
"dubbo.apache.org/dubbo-go/v3/client"
"github.com/gin-gonic/gin"
pbAsset "github.com/topfans/backend/pkg/proto/asset"
pbCommon "github.com/topfans/backend/pkg/proto/common"
)
// ============================================================
// fakeStarbookClient — 实现 pbAsset.AssetService
// 只 stub 关注的 GetMyAssets / GetAssetsByType 两个方法,
// 其余方法通过嵌入 nil 接口在调用时触发 panic(测试不会触发)
// ============================================================
type fakeStarbookClient struct {
pbAsset.AssetService // 嵌入 nil 接口
// GetMyAssets 配置
homeResp *pbAsset.GetMyAssetsResponse
homeErr error
lastHomeReq *pbAsset.GetMyAssetsRequest
homeCallCnt int
// GetAssetsByType 配置
itemsResp *pbAsset.GetAssetsByTypeResponse
itemsErr error
lastItemsReq *pbAsset.GetAssetsByTypeRequest
itemsCallCnt int
}
func newFakeStarbookClient() *fakeStarbookClient {
return &fakeStarbookClient{}
}
func (f *fakeStarbookClient) GetMyAssets(ctx context.Context, req *pbAsset.GetMyAssetsRequest, opts ...client.CallOption) (*pbAsset.GetMyAssetsResponse, error) {
f.homeCallCnt++
f.lastHomeReq = req
if f.homeErr != nil {
return nil, f.homeErr
}
return f.homeResp, nil
}
func (f *fakeStarbookClient) GetAssetsByType(ctx context.Context, req *pbAsset.GetAssetsByTypeRequest, opts ...client.CallOption) (*pbAsset.GetAssetsByTypeResponse, error) {
f.itemsCallCnt++
f.lastItemsReq = req
if f.itemsErr != nil {
return nil, f.itemsErr
}
return f.itemsResp, nil
}
// newAuthedStarbookContext 构造带 user_id + star_id 的 gin.Context(模拟 JWT 中间件已写入)
func newAuthedStarbookContext(userID, starID int64, query string) (*gin.Context, *httptest.ResponseRecorder) {
rec := httptest.NewRecorder()
c, _ := gin.CreateTestContext(rec)
c.Set("user_id", userID)
c.Set("star_id", starID)
c.Request = httptest.NewRequest(http.MethodGet, "/api/v1/starbook/home"+query, nil)
return c, rec
}
// ============================================================
// TestStarbookHome_Success
// mock client,GET /starbook/home
// 期望:HTTP 200 + JSON 含 data.groups(老 starbook controller 的 HomeItems 即 AssetGroup 列表)
// 期望:fake 收到 GetMyAssetsRequest(空参数 = 全部藏品)
// ============================================================
func TestStarbookHome_Success(t *testing.T) {
fake := newFakeStarbookClient()
fake.homeResp = &pbAsset.GetMyAssetsResponse{
Base: &pbCommon.BaseResponse{Code: 0, Message: "ok"},
Data: &pbAsset.AssetListData{
Groups: []*pbAsset.AssetGroup{
{Type: "regular", Category: "castlove", CategoryName: "普通藏品", TotalCount: 3},
{Type: "collection", Category: "love_card", CategoryName: "套装", TotalCount: 1},
},
Total: 4,
Page: 1,
PageSize: 20,
HasMore: false,
},
}
ctrl := &StarbookController{assetClient: fake}
c, rec := newAuthedStarbookContext(42, 7, "")
ctrl.GetStarbookHome(c)
if rec.Code != http.StatusOK {
t.Fatalf("expected HTTP 200, got %d, body=%s", rec.Code, rec.Body.String())
}
var body struct {
Code uint32 `json:"code"`
Message string `json:"message"`
Data interface{} `json:"data"`
}
if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil {
t.Fatalf("invalid JSON: %v, body=%s", err, rec.Body.String())
}
if body.Code != 0 {
t.Errorf("expected code=0, got %d (msg=%s)", body.Code, body.Message)
}
dataMap, ok := body.Data.(map[string]interface{})
if !ok {
t.Fatalf("expected data to be object, got %T", body.Data)
}
// 嵌套结构:Response.Data → proto resp.Data(AssetListData) → groups
// JSON 形如 {"code":0,"message":"ok","data":{"base":{...},"data":{"groups":[...],"total":4,...}}}
innerData, ok := dataMap["data"].(map[string]interface{})
if !ok {
t.Fatalf("expected inner data.data to be object, got %T", dataMap["data"])
}
// groups 即老 starbook controller 的 HomeItems(同 JSON 字段名)
groups, ok := innerData["groups"].([]interface{})
if !ok || len(groups) != 2 {
t.Fatalf("expected groups array len 2, got %v", innerData["groups"])
}
if innerData["total"].(float64) != 4 {
t.Errorf("expected total=4, got %v", innerData["total"])
}
// 断言 fake 收到 GetMyAssets 调用
if fake.homeCallCnt != 1 {
t.Errorf("expected GetMyAssets called once, got %d", fake.homeCallCnt)
}
}
// ============================================================
// TestStarbookHome_Unauthorized
// 没有 user_id(star_id 还在),期望 401,不调下游
// ============================================================
func TestStarbookHome_Unauthorized(t *testing.T) {
fake := newFakeStarbookClient()
ctrl := &StarbookController{assetClient: fake}
rec := httptest.NewRecorder()
c, _ := gin.CreateTestContext(rec)
// 故意不 set user_id
c.Set("star_id", int64(7))
c.Request = httptest.NewRequest(http.MethodGet, "/api/v1/starbook/home", nil)
ctrl.GetStarbookHome(c)
if rec.Code != http.StatusUnauthorized {
t.Fatalf("expected HTTP 401, got %d, body=%s", rec.Code, rec.Body.String())
}
if fake.homeCallCnt != 0 {
t.Errorf("expected GetMyAssets NOT called, got %d calls", fake.homeCallCnt)
}
}
// ============================================================
// TestStarbookHome_RPCError
// fake 返回 error,期望 500,响应中含错误信息
// ============================================================
func TestStarbookHome_RPCError(t *testing.T) {
fake := newFakeStarbookClient()
fake.homeErr = errString("assetService unreachable")
ctrl := &StarbookController{assetClient: fake}
c, rec := newAuthedStarbookContext(42, 7, "")
ctrl.GetStarbookHome(c)
if rec.Code != http.StatusInternalServerError {
t.Fatalf("expected HTTP 500, got %d, body=%s", rec.Code, rec.Body.String())
}
if fake.homeCallCnt != 1 {
t.Errorf("expected GetMyAssets called once, got %d", fake.homeCallCnt)
}
}
// ============================================================
// TestStarbookItems_Success
// mock client,GET /starbook/items?type=regular&category=castlove&grade=2&page=1&page_size=20
// 期望:HTTP 200 + data.groups + 断言 fake 收到的 type/category/grade/page/pageSize
// ============================================================
func TestStarbookItems_Success(t *testing.T) {
fake := newFakeStarbookClient()
fake.itemsResp = &pbAsset.GetAssetsByTypeResponse{
Base: &pbCommon.BaseResponse{Code: 0, Message: "ok"},
Data: &pbAsset.AssetListData{
Groups: []*pbAsset.AssetGroup{
{Type: "regular", Category: "castlove", CategoryName: "铸爱", TotalCount: 5},
},
Total: 5,
Page: 1,
PageSize: 20,
HasMore: false,
},
}
ctrl := &StarbookController{assetClient: fake}
c, rec := newAuthedStarbookContext(42, 7, "?type=regular&category=castlove&grade=2&page=1&page_size=20")
ctrl.GetStarbookItems(c)
if rec.Code != http.StatusOK {
t.Fatalf("expected HTTP 200, got %d, body=%s", rec.Code, rec.Body.String())
}
var body struct {
Code uint32 `json:"code"`
Message string `json:"message"`
Data interface{} `json:"data"`
}
if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil {
t.Fatalf("invalid JSON: %v, body=%s", err, rec.Body.String())
}
if body.Code != 0 {
t.Errorf("expected code=0, got %d (msg=%s)", body.Code, body.Message)
}
dataMap, ok := body.Data.(map[string]interface{})
if !ok {
t.Fatalf("expected data to be object, got %T", body.Data)
}
innerData, ok := dataMap["data"].(map[string]interface{})
if !ok {
t.Fatalf("expected inner data.data to be object, got %T", dataMap["data"])
}
groups, ok := innerData["groups"].([]interface{})
if !ok || len(groups) != 1 {
t.Fatalf("expected groups array len 1, got %v", innerData["groups"])
}
// 断言 fake 收到正确的 RPC 参数
if fake.itemsCallCnt != 1 {
t.Errorf("expected GetAssetsByType called once, got %d", fake.itemsCallCnt)
}
if fake.lastItemsReq.GetType() != "regular" {
t.Errorf("Type mismatch: got %q", fake.lastItemsReq.GetType())
}
if fake.lastItemsReq.GetCategory() != "castlove" {
t.Errorf("Category mismatch: got %q", fake.lastItemsReq.GetCategory())
}
if fake.lastItemsReq.GetGrade() != 2 {
t.Errorf("Grade mismatch: got %d", fake.lastItemsReq.GetGrade())
}
if fake.lastItemsReq.GetPage() != 1 {
t.Errorf("Page mismatch: got %d", fake.lastItemsReq.GetPage())
}
if fake.lastItemsReq.GetPageSize() != 20 {
t.Errorf("PageSize mismatch: got %d", fake.lastItemsReq.GetPageSize())
}
}
// ============================================================
// TestStarbookItems_MissingType
// 不带 type 参数,期望 400,不调下游
// ============================================================
func TestStarbookItems_MissingType(t *testing.T) {
fake := newFakeStarbookClient()
ctrl := &StarbookController{assetClient: fake}
c, rec := newAuthedStarbookContext(42, 7, "")
ctrl.GetStarbookItems(c)
if rec.Code != http.StatusBadRequest {
t.Fatalf("expected HTTP 400, got %d, body=%s", rec.Code, rec.Body.String())
}
if fake.itemsCallCnt != 0 {
t.Errorf("expected GetAssetsByType NOT called, got %d calls", fake.itemsCallCnt)
}
}
// ============================================================
// errString 简单错误包装(避免引入 errors 包造成额外依赖)
// ============================================================
type errString string
func (e errString) Error() string { return string(e) }