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>
273 lines
9.3 KiB
Go
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) }
|