- lucky_gift_experiments/overrides 表(006 迁移,含分组统计索引与 binary user_key) - 创建实验=同事务发布 treatment+登记;两组强制同策略;实验期常规发布拒绝 - Check/单抽/批量/外部抽按用户钉住组内版本;结束实验重发布获胜快照全量收敛 - AdminLuckyGiftService 新增 5 个实验 RPC(放量字段 proto3 optional) - admin 实验管理 HTTP API + lucky-gift:experiment 权限(迁移 103) - 设计文档 docs/幸运礼物AB实验.md;域/真库/admin 回归测试 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
182 lines
7.5 KiB
Go
182 lines
7.5 KiB
Go
package opscenter
|
|
|
|
import (
|
|
"context"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"strings"
|
|
"testing"
|
|
|
|
"hyapp-admin-server/internal/integration/luckygiftadmin"
|
|
"hyapp-admin-server/internal/middleware"
|
|
luckygiftmodule "hyapp-admin-server/internal/modules/luckygift"
|
|
luckygiftv1 "hyapp.local/api/proto/luckygift/v1"
|
|
|
|
"github.com/gin-gonic/gin"
|
|
)
|
|
|
|
type poolPermissionClient struct {
|
|
luckygiftadmin.Client
|
|
}
|
|
|
|
func (poolPermissionClient) AdjustLuckyGiftPoolBalance(_ context.Context, req *luckygiftv1.AdjustLuckyGiftPoolBalanceRequest) (*luckygiftv1.AdjustLuckyGiftPoolBalanceResponse, error) {
|
|
return &luckygiftv1.AdjustLuckyGiftPoolBalanceResponse{
|
|
Adjustment: &luckygiftv1.LuckyGiftPoolAdjustment{
|
|
AdjustmentId: req.GetAdjustmentId(), AppCode: req.GetMeta().GetAppCode(), PoolId: req.GetPoolId(),
|
|
StrategyVersion: req.GetStrategyVersion(), Direction: req.GetDirection(), AmountCoins: req.GetAmountCoins(),
|
|
},
|
|
Pool: &luckygiftv1.LuckyGiftPoolBalance{
|
|
AppCode: req.GetMeta().GetAppCode(), PoolId: req.GetPoolId(), StrategyVersion: req.GetStrategyVersion(), Materialized: true,
|
|
},
|
|
}, nil
|
|
}
|
|
|
|
func TestRegisterRoutesExposesStrategyPoolAdjustmentEndpoints(t *testing.T) {
|
|
gin.SetMode(gin.TestMode)
|
|
engine := gin.New()
|
|
RegisterRoutes(engine.Group("/api/v1"), New(nil, luckygiftmodule.New(poolPermissionClient{}, 0, nil)))
|
|
routes := make(map[string]struct{})
|
|
for _, route := range engine.Routes() {
|
|
routes[route.Method+" "+route.Path] = struct{}{}
|
|
}
|
|
for _, expected := range []string{
|
|
"POST /api/v1/admin/ops-center/lucky-gifts/pools/credit",
|
|
"POST /api/v1/admin/ops-center/lucky-gifts/pools/debit",
|
|
"GET /api/v1/admin/ops-center/lucky-gifts/experiments",
|
|
"POST /api/v1/admin/ops-center/lucky-gifts/experiments",
|
|
"PATCH /api/v1/admin/ops-center/lucky-gifts/experiments/:experimentId",
|
|
"PUT /api/v1/admin/ops-center/lucky-gifts/experiments/:experimentId/overrides",
|
|
"POST /api/v1/admin/ops-center/lucky-gifts/experiments/:experimentId/end",
|
|
} {
|
|
if _, ok := routes[expected]; !ok {
|
|
t.Fatalf("missing route %s; actual=%v", expected, routes)
|
|
}
|
|
}
|
|
}
|
|
|
|
type experimentPermissionClient struct {
|
|
luckygiftadmin.Client
|
|
}
|
|
|
|
func (experimentPermissionClient) ListLuckyGiftExperiments(_ context.Context, _ *luckygiftv1.ListLuckyGiftExperimentsRequest) (*luckygiftv1.ListLuckyGiftExperimentsResponse, error) {
|
|
return &luckygiftv1.ListLuckyGiftExperimentsResponse{}, nil
|
|
}
|
|
|
|
func (experimentPermissionClient) CreateLuckyGiftExperiment(_ context.Context, req *luckygiftv1.CreateLuckyGiftExperimentRequest) (*luckygiftv1.CreateLuckyGiftExperimentResponse, error) {
|
|
return &luckygiftv1.CreateLuckyGiftExperimentResponse{
|
|
Experiment: &luckygiftv1.LuckyGiftExperiment{ExperimentId: "lucky_exp_route", Name: req.GetName()},
|
|
TreatmentConfig: req.GetTreatmentConfig(),
|
|
}, nil
|
|
}
|
|
|
|
func (experimentPermissionClient) UpdateLuckyGiftExperiment(_ context.Context, req *luckygiftv1.UpdateLuckyGiftExperimentRequest) (*luckygiftv1.UpdateLuckyGiftExperimentResponse, error) {
|
|
return &luckygiftv1.UpdateLuckyGiftExperimentResponse{
|
|
Experiment: &luckygiftv1.LuckyGiftExperiment{ExperimentId: req.GetExperimentId()},
|
|
}, nil
|
|
}
|
|
|
|
func TestExperimentRoutesRequireExperimentPermission(t *testing.T) {
|
|
gin.SetMode(gin.TestMode)
|
|
tests := []struct {
|
|
name string
|
|
method string
|
|
target string
|
|
body string
|
|
permission string
|
|
wantStatus int
|
|
}{
|
|
{
|
|
name: "list allowed with view", method: http.MethodGet,
|
|
target: "/api/v1/admin/ops-center/lucky-gifts/experiments?app_code=lalu",
|
|
body: "", permission: "lucky-gift:view", wantStatus: http.StatusOK,
|
|
},
|
|
{
|
|
name: "create allowed with experiment", method: http.MethodPost,
|
|
target: "/api/v1/admin/ops-center/lucky-gifts/experiments?app_code=lalu",
|
|
body: `{"name":"exp","treatment_traffic_percent":5,"config":{"pool_id":"lucky"}}`,
|
|
permission: "lucky-gift:experiment", wantStatus: http.StatusOK,
|
|
},
|
|
{
|
|
name: "create rejects update permission", method: http.MethodPost,
|
|
target: "/api/v1/admin/ops-center/lucky-gifts/experiments?app_code=lalu",
|
|
body: `{"name":"exp","treatment_traffic_percent":5,"config":{"pool_id":"lucky"}}`,
|
|
permission: "lucky-gift:update", wantStatus: http.StatusForbidden,
|
|
},
|
|
{
|
|
name: "patch allowed with experiment", method: http.MethodPatch,
|
|
target: "/api/v1/admin/ops-center/lucky-gifts/experiments/lucky_exp_route?app_code=lalu",
|
|
body: `{"status":"paused"}`, permission: "lucky-gift:experiment", wantStatus: http.StatusOK,
|
|
},
|
|
{
|
|
name: "patch rejects view permission", method: http.MethodPatch,
|
|
target: "/api/v1/admin/ops-center/lucky-gifts/experiments/lucky_exp_route?app_code=lalu",
|
|
body: `{"status":"paused"}`, permission: "lucky-gift:view", wantStatus: http.StatusForbidden,
|
|
},
|
|
{
|
|
name: "end rejects view permission", method: http.MethodPost,
|
|
target: "/api/v1/admin/ops-center/lucky-gifts/experiments/lucky_exp_route/end?app_code=lalu",
|
|
body: `{"winner_arm":"control"}`, permission: "lucky-gift:view", wantStatus: http.StatusForbidden,
|
|
},
|
|
{
|
|
name: "overrides rejects update permission", method: http.MethodPut,
|
|
target: "/api/v1/admin/ops-center/lucky-gifts/experiments/lucky_exp_route/overrides?app_code=lalu",
|
|
body: `{"upserts":[]}`, permission: "lucky-gift:update", wantStatus: http.StatusForbidden,
|
|
},
|
|
}
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
engine := gin.New()
|
|
engine.Use(func(c *gin.Context) {
|
|
c.Set(middleware.ContextPermissions, []string{tt.permission})
|
|
c.Set(middleware.ContextUserID, uint(7))
|
|
c.Next()
|
|
})
|
|
RegisterRoutes(engine.Group("/api/v1"), New(nil, luckygiftmodule.New(experimentPermissionClient{}, 0, nil)))
|
|
recorder := httptest.NewRecorder()
|
|
request := httptest.NewRequest(tt.method, tt.target, strings.NewReader(tt.body))
|
|
request.Header.Set("Content-Type", "application/json")
|
|
engine.ServeHTTP(recorder, request)
|
|
if recorder.Code != tt.wantStatus {
|
|
t.Fatalf("status=%d want=%d body=%s", recorder.Code, tt.wantStatus, recorder.Body.String())
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestPoolAdjustmentRoutesRequireDirectionSpecificPermission(t *testing.T) {
|
|
gin.SetMode(gin.TestMode)
|
|
tests := []struct {
|
|
name string
|
|
path string
|
|
permission string
|
|
wantStatus int
|
|
}{
|
|
{name: "credit allowed", path: "credit", permission: "lucky-gift:pool-credit", wantStatus: http.StatusOK},
|
|
{name: "credit rejects debit permission", path: "credit", permission: "lucky-gift:pool-debit", wantStatus: http.StatusForbidden},
|
|
{name: "debit allowed", path: "debit", permission: "lucky-gift:pool-debit", wantStatus: http.StatusOK},
|
|
{name: "debit rejects view permission", path: "debit", permission: "lucky-gift:view", wantStatus: http.StatusForbidden},
|
|
}
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
engine := gin.New()
|
|
engine.Use(func(c *gin.Context) {
|
|
c.Set(middleware.ContextPermissions, []string{tt.permission})
|
|
c.Set(middleware.ContextUserID, uint(7))
|
|
c.Next()
|
|
})
|
|
RegisterRoutes(engine.Group("/api/v1"), New(nil, luckygiftmodule.New(poolPermissionClient{}, 0, nil)))
|
|
recorder := httptest.NewRecorder()
|
|
request := httptest.NewRequest(
|
|
http.MethodPost,
|
|
"/api/v1/admin/ops-center/lucky-gifts/pools/"+tt.path+"?app_code=aslan&pool_id=lucky&strategy_version=dynamic_v3",
|
|
strings.NewReader(`{"adjustment_id":"route-adjustment","amount_coins":1,"reason":"permission test"}`),
|
|
)
|
|
request.Header.Set("Content-Type", "application/json")
|
|
engine.ServeHTTP(recorder, request)
|
|
if recorder.Code != tt.wantStatus {
|
|
t.Fatalf("status=%d want=%d body=%s", recorder.Code, tt.wantStatus, recorder.Body.String())
|
|
}
|
|
})
|
|
}
|
|
}
|