package resource import ( "context" "fmt" "net/http" "net/http/httptest" "strings" "testing" "time" "hyapp-admin-server/internal/appctx" "hyapp-admin-server/internal/integration/walletclient" "hyapp-admin-server/internal/middleware" walletv1 "hyapp.local/api/proto/wallet/v1" "github.com/DATA-DOG/go-sqlmock" "github.com/gin-gonic/gin" ) func TestUpdateMP4ResourceLayoutsPreservesResourceFields(t *testing.T) { wallet := &mockResourceWallet{resources: map[int64]*walletv1.Resource{ 11: { AppCode: "lalu", ResourceId: 11, ResourceCode: "profile_card_star", ResourceType: resourceTypeProfileCard, Name: "星光资料卡", Status: "active", Grantable: true, GrantStrategy: "extend_expiry", WalletAssetType: "RESOURCE", WalletAssetAmount: 1, UsageScopes: []string{resourceTypeProfileCard}, AssetUrl: "https://cdn.example.test/profile_card.png", PreviewUrl: "https://cdn.example.test/profile_card_cover.png", AnimationUrl: "https://cdn.example.test/profile_card.mp4?token=1", MetadataJson: `{"profile_card_layout":{"source_width":1136,"source_height":1680,"color_content_width":750,"content_top":117,"content_bottom":1666,"content_height":1550,"detect_version":1}}`, SortOrder: 9, ManagerGrantEnabled: true, PriceType: resourcePriceTypeCoin, CoinPrice: 100, }, }} router := newResourceHandlerTestRouter(New(wallet, nil, nil, time.Second, nil)) metadataJSON := `{ "animation_format":"mp4", "mp4_alpha_layout":{ "alpha_layout":"alpha_left_rgb_right", "video_w":1500, "video_h":1334, "rgb_frame":[750,0,750,1334], "alpha_frame":[0,0,750,1334], "confirmed":true, "detect_version":1 }, "debug":true }` body := fmt.Sprintf(`{"items":[{"resourceId":11,"metadataJson":%q}]}`, metadataJSON) recorder := httptest.NewRecorder() request := httptest.NewRequest(http.MethodPut, "/admin/resources/mp4-layouts/batch", strings.NewReader(body)) request.Header.Set("Content-Type", "application/json") router.ServeHTTP(recorder, request) if recorder.Code != http.StatusOK { t.Fatalf("batch mp4 layout update status mismatch: %d %s", recorder.Code, recorder.Body.String()) } if len(wallet.updates) != 1 { t.Fatalf("expected one wallet update, got %d", len(wallet.updates)) } update := wallet.updates[0] if update.GetResourceId() != 11 || update.GetName() != "星光资料卡" || update.GetStatus() != "active" || update.GetCoinPrice() != 100 { t.Fatalf("resource fields should be preserved: %+v", update) } if update.ManagerGrantEnabled == nil || !update.GetManagerGrantEnabled() || update.GetOperatorUserId() != 7 { t.Fatalf("operator and manager grant should be preserved: %+v", update) } if !strings.Contains(update.GetMetadataJson(), `"profile_card_layout"`) || !strings.Contains(update.GetMetadataJson(), `"mp4_alpha_layout"`) { t.Fatalf("profile card and mp4 metadata should both be kept: %s", update.GetMetadataJson()) } if strings.Contains(update.GetMetadataJson(), "debug") { t.Fatalf("debug metadata should be dropped: %s", update.GetMetadataJson()) } } func TestUpdateMP4ResourceLayoutsIgnoresNonMP4Resource(t *testing.T) { wallet := &mockResourceWallet{resources: map[int64]*walletv1.Resource{ 12: { AppCode: "lalu", ResourceId: 12, ResourceCode: "gift_rose", ResourceType: "gift", Name: "玫瑰", Status: "active", AnimationUrl: "https://cdn.example.test/gift.svga", MetadataJson: "{}", GrantStrategy: "increase_quantity", PriceType: resourcePriceTypeCoin, CoinPrice: 10, }, }} router := newResourceHandlerTestRouter(New(wallet, nil, nil, time.Second, nil)) body := fmt.Sprintf(`{"items":[{"resourceId":12,"metadataJson":%q}]}`, mp4AlphaLayoutMetadataJSON("alpha_left_rgb_right", true)) recorder := httptest.NewRecorder() request := httptest.NewRequest(http.MethodPut, "/admin/resources/mp4-layouts/batch", strings.NewReader(body)) request.Header.Set("Content-Type", "application/json") router.ServeHTTP(recorder, request) if recorder.Code != http.StatusOK { t.Fatalf("non-mp4 resource should be ignored, got %d %s", recorder.Code, recorder.Body.String()) } if len(wallet.updates) != 0 { t.Fatalf("non-mp4 resource should not update wallet") } } func TestDeleteGiftOnlyCallsGiftConfigDelete(t *testing.T) { wallet := &mockResourceWallet{} router := newResourceHandlerTestRouter(New(wallet, nil, nil, time.Second, nil)) recorder := httptest.NewRecorder() request := httptest.NewRequest(http.MethodDelete, "/admin/gifts/rose", nil) router.ServeHTTP(recorder, request) if recorder.Code != http.StatusOK { t.Fatalf("delete gift status mismatch: %d %s", recorder.Code, recorder.Body.String()) } if len(wallet.deletedGifts) != 1 { t.Fatalf("expected one gift delete request, got %d", len(wallet.deletedGifts)) } deleteReq := wallet.deletedGifts[0] if deleteReq.GetAppCode() != "lalu" || deleteReq.GetGiftId() != "rose" || deleteReq.GetOperatorUserId() != 7 { t.Fatalf("delete gift request mismatch: %+v", deleteReq) } } func TestIdentityAutoGrantConfigRouteDoesNotHitResourceGroupID(t *testing.T) { router := newResourceHandlerTestRouter(New(&mockResourceWallet{}, nil, nil, time.Second, nil)) recorder := httptest.NewRecorder() request := httptest.NewRequest(http.MethodGet, "/admin/resource-groups/identity-auto-grant-config", nil) router.ServeHTTP(recorder, request) // 这里故意使用 nil store,让配置 handler 返回 500;如果路由被 :group_id 抢走,会变成 400 的 ID 参数错误。 if recorder.Code != http.StatusInternalServerError { t.Fatalf("identity config route should hit config handler, got %d %s", recorder.Code, recorder.Body.String()) } if strings.Contains(recorder.Body.String(), "ID 参数不正确") { t.Fatalf("identity config route should not be parsed as group_id: %s", recorder.Body.String()) } } func TestListResourceGrantsResolvesDisplayIDBeforeWalletFilter(t *testing.T) { db, sqlMock, err := sqlmock.New() if err != nil { t.Fatalf("create sql mock failed: %v", err) } defer db.Close() wallet := &mockResourceWallet{} router := newResourceHandlerTestRouter(New(wallet, nil, db, time.Second, nil)) resolvedUserID := int64(327702592329093120) sqlMock.ExpectQuery(`(?s)SELECT u\.user_id.*FROM users u.*LIMIT 1`). WithArgs( "lalu", "111", "111", "111", sqlmock.AnyArg(), "111", "%111%", "111", "111", "111", sqlmock.AnyArg(), "111", ). WillReturnRows(sqlmock.NewRows([]string{"user_id"}).AddRow(resolvedUserID)) recorder := httptest.NewRecorder() request := httptest.NewRequest(http.MethodGet, "/admin/resource-grants?target_user_id=111", nil) router.ServeHTTP(recorder, request) if recorder.Code != http.StatusOK { t.Fatalf("list grants status mismatch: %d %s", recorder.Code, recorder.Body.String()) } if len(wallet.listGrantRequests) != 1 { t.Fatalf("expected one wallet list request, got %d", len(wallet.listGrantRequests)) } if got := wallet.listGrantRequests[0].GetTargetUserId(); got != resolvedUserID { t.Fatalf("target display id should be resolved before wallet filter: got %d want %d", got, resolvedUserID) } if err := sqlMock.ExpectationsWereMet(); err != nil { t.Fatalf("sql expectations mismatch: %v", err) } } func newResourceHandlerTestRouter(handler *Handler) *gin.Engine { gin.SetMode(gin.TestMode) router := gin.New() router.Use(func(c *gin.Context) { c.Request = c.Request.WithContext(appctx.WithContext(c.Request.Context(), "lalu")) c.Set(middleware.ContextRequestID, "resource-handler-test") c.Set(middleware.ContextUserID, uint(7)) c.Set(middleware.ContextUsername, "tester") // 测试路由走真实 RegisterRoutes,权限一次性放开,避免中间件短路导致无法覆盖路由优先级。 c.Set(middleware.ContextPermissions, []string{ "emoji-pack:create", "emoji-pack:view", "gift:delete", "gift:view", "gift:update", "gift:create", "gift:status", "resource:create", "resource-grant:create", "resource-grant:revoke", "resource-grant:view", "resource-group:create", "resource-group:update", "resource-group:view", "resource-shop:update", "resource-shop:view", "resource:update", "resource:view", }) c.Next() }) RegisterRoutes(router.Group(""), handler) return router } type mockResourceWallet struct { walletclient.Client resources map[int64]*walletv1.Resource updates []*walletv1.UpdateResourceRequest deletedGifts []*walletv1.DeleteGiftConfigRequest listGrantRequests []*walletv1.ListResourceGrantsRequest } func (m *mockResourceWallet) GetResource(ctx context.Context, req *walletv1.GetResourceRequest) (*walletv1.GetResourceResponse, error) { resource := m.resources[req.GetResourceId()] if resource == nil { return nil, fmt.Errorf("resource not found") } return &walletv1.GetResourceResponse{Resource: resource}, nil } func (m *mockResourceWallet) UpdateResource(ctx context.Context, req *walletv1.UpdateResourceRequest) (*walletv1.ResourceResponse, error) { m.updates = append(m.updates, req) return &walletv1.ResourceResponse{Resource: &walletv1.Resource{ AppCode: req.GetAppCode(), ResourceId: req.GetResourceId(), ResourceCode: req.GetResourceCode(), ResourceType: req.GetResourceType(), Name: req.GetName(), Status: req.GetStatus(), Grantable: req.GetGrantable(), GrantStrategy: req.GetGrantStrategy(), WalletAssetType: req.GetWalletAssetType(), WalletAssetAmount: req.GetWalletAssetAmount(), UsageScopes: req.GetUsageScopes(), AssetUrl: req.GetAssetUrl(), PreviewUrl: req.GetPreviewUrl(), AnimationUrl: req.GetAnimationUrl(), MetadataJson: req.GetMetadataJson(), SortOrder: req.GetSortOrder(), ManagerGrantEnabled: req.GetManagerGrantEnabled(), PriceType: req.GetPriceType(), CoinPrice: req.GetCoinPrice(), }}, nil } func (m *mockResourceWallet) DeleteGiftConfig(ctx context.Context, req *walletv1.DeleteGiftConfigRequest) (*walletv1.GiftConfigResponse, error) { m.deletedGifts = append(m.deletedGifts, req) return &walletv1.GiftConfigResponse{Gift: &walletv1.GiftConfig{ AppCode: req.GetAppCode(), GiftId: req.GetGiftId(), ResourceId: 11, Status: "active", Name: "Rose", }}, nil } func (m *mockResourceWallet) ListResourceGrants(ctx context.Context, req *walletv1.ListResourceGrantsRequest) (*walletv1.ListResourceGrantsResponse, error) { // 列表过滤的修正点发生在 admin-server 调 wallet 之前;测试只需要捕获请求形状, // 返回空页即可避免后续用户资料补全查询干扰断言。 m.listGrantRequests = append(m.listGrantRequests, req) return &walletv1.ListResourceGrantsResponse{Grants: []*walletv1.ResourceGrant{}, Total: 0}, nil }