- 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>
278 lines
12 KiB
Go
278 lines
12 KiB
Go
package luckygift
|
||
|
||
import (
|
||
"bytes"
|
||
"context"
|
||
"encoding/json"
|
||
"net/http"
|
||
"net/http/httptest"
|
||
"testing"
|
||
"time"
|
||
|
||
"hyapp-admin-server/internal/integration/luckygiftadmin"
|
||
"hyapp-admin-server/internal/middleware"
|
||
luckygiftv1 "hyapp.local/api/proto/luckygift/v1"
|
||
|
||
"github.com/gin-gonic/gin"
|
||
)
|
||
|
||
type experimentClientStub struct {
|
||
luckygiftadmin.Client
|
||
createRequest *luckygiftv1.CreateLuckyGiftExperimentRequest
|
||
createResponse *luckygiftv1.CreateLuckyGiftExperimentResponse
|
||
updateRequest *luckygiftv1.UpdateLuckyGiftExperimentRequest
|
||
updateResponse *luckygiftv1.UpdateLuckyGiftExperimentResponse
|
||
overridesRequest *luckygiftv1.SetLuckyGiftExperimentOverridesRequest
|
||
endRequest *luckygiftv1.EndLuckyGiftExperimentRequest
|
||
endResponse *luckygiftv1.EndLuckyGiftExperimentResponse
|
||
err error
|
||
}
|
||
|
||
func (s *experimentClientStub) CreateLuckyGiftExperiment(_ context.Context, request *luckygiftv1.CreateLuckyGiftExperimentRequest) (*luckygiftv1.CreateLuckyGiftExperimentResponse, error) {
|
||
s.createRequest = request
|
||
return s.createResponse, s.err
|
||
}
|
||
|
||
func (s *experimentClientStub) UpdateLuckyGiftExperiment(_ context.Context, request *luckygiftv1.UpdateLuckyGiftExperimentRequest) (*luckygiftv1.UpdateLuckyGiftExperimentResponse, error) {
|
||
s.updateRequest = request
|
||
return s.updateResponse, s.err
|
||
}
|
||
|
||
func (s *experimentClientStub) SetLuckyGiftExperimentOverrides(_ context.Context, request *luckygiftv1.SetLuckyGiftExperimentOverridesRequest) (*luckygiftv1.SetLuckyGiftExperimentOverridesResponse, error) {
|
||
s.overridesRequest = request
|
||
return &luckygiftv1.SetLuckyGiftExperimentOverridesResponse{
|
||
Experiment: &luckygiftv1.LuckyGiftExperiment{ExperimentId: request.GetExperimentId(), Overrides: request.GetUpserts()},
|
||
}, s.err
|
||
}
|
||
|
||
func (s *experimentClientStub) EndLuckyGiftExperiment(_ context.Context, request *luckygiftv1.EndLuckyGiftExperimentRequest) (*luckygiftv1.EndLuckyGiftExperimentResponse, error) {
|
||
s.endRequest = request
|
||
return s.endResponse, s.err
|
||
}
|
||
|
||
func experimentTestContext(t *testing.T, method, target, body string) (*gin.Context, *httptest.ResponseRecorder) {
|
||
t.Helper()
|
||
recorder := httptest.NewRecorder()
|
||
c, _ := gin.CreateTestContext(recorder)
|
||
c.Request = httptest.NewRequest(method, target, bytes.NewBufferString(body))
|
||
c.Request.Header.Set("Content-Type", "application/json")
|
||
c.Set(middleware.ContextUserID, uint(7))
|
||
c.Set(middleware.ContextRequestID, "request-1")
|
||
return c, recorder
|
||
}
|
||
|
||
func TestCreateLuckyGiftExperimentReusesConfigNormalizationAndPPM(t *testing.T) {
|
||
gin.SetMode(gin.TestMode)
|
||
client := &experimentClientStub{createResponse: &luckygiftv1.CreateLuckyGiftExperimentResponse{
|
||
Experiment: &luckygiftv1.LuckyGiftExperiment{
|
||
AppCode: "lalu", PoolId: "super_lucky", ExperimentId: "lucky_exp_1", Name: "rtp push",
|
||
ControlRuleVersion: 9, TreatmentRuleVersion: 10, TreatmentTrafficPpm: 50_000, Status: "running",
|
||
Overrides: []*luckygiftv1.LuckyGiftExperimentOverride{{UserKey: "1001", Arm: "treatment"}},
|
||
ArmStats: []*luckygiftv1.LuckyGiftExperimentArmStats{{Arm: "control", RuleVersion: 9, ActualRtpPpm: 955_000}},
|
||
},
|
||
TreatmentConfig: &luckygiftv1.LuckyGiftRuleConfig{AppCode: "lalu", PoolId: "super_lucky", RuleVersion: 10, TargetRtpPpm: 960_000},
|
||
}}
|
||
body := `{
|
||
"name": " rtp push ",
|
||
"treatment_traffic_percent": 5,
|
||
"overrides": [{"user_key": " 1001 ", "arm": " TREATMENT "}],
|
||
"config": {
|
||
"pool_id": "super_lucky",
|
||
"strategy_version": "DYNAMIC_V3",
|
||
"enabled": true,
|
||
"target_rtp_percent": 96,
|
||
"initial_pool_coins": 1000000,
|
||
"stages": [{"stage": "normal", "tiers": [{"multiplier": 0.5, "probability_percent": 22, "enabled": true}]}]
|
||
}
|
||
}`
|
||
c, recorder := experimentTestContext(t, http.MethodPost, "/admin/ops-center/lucky-gifts/experiments?app_code=LALU", body)
|
||
New(client, time.Second, nil).CreateLuckyGiftExperiment(c)
|
||
if recorder.Code != http.StatusOK {
|
||
t.Fatalf("status=%d body=%s", recorder.Code, recorder.Body.String())
|
||
}
|
||
request := client.createRequest
|
||
if request == nil {
|
||
t.Fatal("owner create request was not sent")
|
||
}
|
||
if request.GetName() != "rtp push" || request.GetTreatmentTrafficPpm() != 50_000 || request.GetOperatorAdminId() != 7 {
|
||
t.Fatalf("owner request=%+v", request)
|
||
}
|
||
if request.GetMeta().GetAppCode() != "lalu" || request.GetMeta().GetRequestId() != "request-1" {
|
||
t.Fatalf("owner request meta=%+v", request.GetMeta())
|
||
}
|
||
if len(request.GetOverrides()) != 1 || request.GetOverrides()[0].GetUserKey() != "1001" || request.GetOverrides()[0].GetArm() != "treatment" {
|
||
t.Fatalf("owner request overrides=%+v", request.GetOverrides())
|
||
}
|
||
config := request.GetTreatmentConfig()
|
||
if config.GetAppCode() != "lalu" || config.GetStrategyVersion() != "dynamic_v3" {
|
||
t.Fatalf("treatment config app/strategy=%+v", config)
|
||
}
|
||
if config.GetTargetRtpPpm() != 960_000 {
|
||
t.Fatalf("treatment config target rtp ppm=%d, want 960000", config.GetTargetRtpPpm())
|
||
}
|
||
if config.GetInitialPoolCoins() != 0 {
|
||
t.Fatalf("dynamic_v3 initial pool coins should be forced to 0, got %d", config.GetInitialPoolCoins())
|
||
}
|
||
tier := config.GetStages()[0].GetTiers()[0]
|
||
if tier.GetTierId() != "normal_0_5x" || tier.GetBaseWeightPpm() != 220_000 {
|
||
t.Fatalf("treatment config tier=%+v", tier)
|
||
}
|
||
var envelope struct {
|
||
Code int `json:"code"`
|
||
Data struct {
|
||
Experiment struct {
|
||
ExperimentID string `json:"experiment_id"`
|
||
TreatmentTrafficPercent float64 `json:"treatment_traffic_percent"`
|
||
ArmStats []struct {
|
||
Arm string `json:"arm"`
|
||
ActualRTPPercent float64 `json:"actual_rtp_percent"`
|
||
} `json:"arm_stats"`
|
||
} `json:"experiment"`
|
||
TreatmentConfig struct {
|
||
RuleVersion int64 `json:"rule_version"`
|
||
TargetRTPPercent float64 `json:"target_rtp_percent"`
|
||
} `json:"treatment_config"`
|
||
} `json:"data"`
|
||
}
|
||
if err := json.Unmarshal(recorder.Body.Bytes(), &envelope); err != nil {
|
||
t.Fatalf("decode response: %v", err)
|
||
}
|
||
if envelope.Code != 0 || envelope.Data.Experiment.ExperimentID != "lucky_exp_1" || envelope.Data.Experiment.TreatmentTrafficPercent != 5 {
|
||
t.Fatalf("response=%s", recorder.Body.String())
|
||
}
|
||
if len(envelope.Data.Experiment.ArmStats) != 1 || envelope.Data.Experiment.ArmStats[0].ActualRTPPercent != 95.5 {
|
||
t.Fatalf("arm stats response=%s", recorder.Body.String())
|
||
}
|
||
if envelope.Data.TreatmentConfig.RuleVersion != 10 || envelope.Data.TreatmentConfig.TargetRTPPercent != 96 {
|
||
t.Fatalf("treatment config response=%s", recorder.Body.String())
|
||
}
|
||
}
|
||
|
||
func TestUpdateLuckyGiftExperimentOmittedFieldsKeepOwnerDefaults(t *testing.T) {
|
||
gin.SetMode(gin.TestMode)
|
||
// 缺省流量以 proto3 optional 的"字段不存在"表达(wantTraffic=nil),显式 0 必须以存在的 0 传递;
|
||
// 这是防止"仅改状态的调用把放量静默清零"的契约核心。
|
||
tests := []struct {
|
||
name string
|
||
body string
|
||
wantTraffic *int64
|
||
wantStatus string
|
||
}{
|
||
{name: "omitted fields", body: `{}`, wantTraffic: nil, wantStatus: ""},
|
||
{name: "explicit zero traffic", body: `{"treatment_traffic_percent": 0}`, wantTraffic: int64Ptr(0), wantStatus: ""},
|
||
{name: "pause only", body: `{"status": "paused"}`, wantTraffic: nil, wantStatus: "paused"},
|
||
{name: "both fields", body: `{"treatment_traffic_percent": 12.5, "status": "running"}`, wantTraffic: int64Ptr(125_000), wantStatus: "running"},
|
||
}
|
||
for _, tt := range tests {
|
||
t.Run(tt.name, func(t *testing.T) {
|
||
client := &experimentClientStub{updateResponse: &luckygiftv1.UpdateLuckyGiftExperimentResponse{
|
||
Experiment: &luckygiftv1.LuckyGiftExperiment{ExperimentId: "lucky_exp_1", Status: "running"},
|
||
}}
|
||
c, recorder := experimentTestContext(t, http.MethodPatch, "/admin/ops-center/lucky-gifts/experiments/lucky_exp_1?app_code=lalu", tt.body)
|
||
c.Params = gin.Params{{Key: "experimentId", Value: "lucky_exp_1"}}
|
||
New(client, time.Second, nil).UpdateLuckyGiftExperiment(c)
|
||
if recorder.Code != http.StatusOK {
|
||
t.Fatalf("status=%d body=%s", recorder.Code, recorder.Body.String())
|
||
}
|
||
request := client.updateRequest
|
||
if request == nil || request.GetExperimentId() != "lucky_exp_1" || request.GetOperatorAdminId() != 7 {
|
||
t.Fatalf("owner request=%+v", request)
|
||
}
|
||
if (request.TreatmentTrafficPpm == nil) != (tt.wantTraffic == nil) ||
|
||
(tt.wantTraffic != nil && request.GetTreatmentTrafficPpm() != *tt.wantTraffic) ||
|
||
request.GetStatus() != tt.wantStatus {
|
||
t.Fatalf("traffic=%v status=%q, want traffic=%v status=%q", request.TreatmentTrafficPpm, request.GetStatus(), tt.wantTraffic, tt.wantStatus)
|
||
}
|
||
})
|
||
}
|
||
}
|
||
|
||
func int64Ptr(v int64) *int64 { return &v }
|
||
|
||
func TestSetLuckyGiftExperimentOverridesForwardsUpsertsAndRemoves(t *testing.T) {
|
||
gin.SetMode(gin.TestMode)
|
||
client := &experimentClientStub{}
|
||
body := `{"upserts": [{"user_key": "ext:abc", "arm": "control"}], "removes": [" 1001 "]}`
|
||
c, recorder := experimentTestContext(t, http.MethodPut, "/admin/ops-center/lucky-gifts/experiments/lucky_exp_1/overrides?app_code=lalu", body)
|
||
c.Params = gin.Params{{Key: "experimentId", Value: "lucky_exp_1"}}
|
||
New(client, time.Second, nil).SetLuckyGiftExperimentOverrides(c)
|
||
if recorder.Code != http.StatusOK {
|
||
t.Fatalf("status=%d body=%s", recorder.Code, recorder.Body.String())
|
||
}
|
||
request := client.overridesRequest
|
||
if request == nil || request.GetExperimentId() != "lucky_exp_1" || request.GetOperatorAdminId() != 7 {
|
||
t.Fatalf("owner request=%+v", request)
|
||
}
|
||
if len(request.GetUpserts()) != 1 || request.GetUpserts()[0].GetUserKey() != "ext:abc" || request.GetUpserts()[0].GetArm() != "control" {
|
||
t.Fatalf("owner upserts=%+v", request.GetUpserts())
|
||
}
|
||
if len(request.GetRemoves()) != 1 || request.GetRemoves()[0] != "1001" {
|
||
t.Fatalf("owner removes=%+v", request.GetRemoves())
|
||
}
|
||
var envelope struct {
|
||
Data struct {
|
||
Experiment struct {
|
||
ExperimentID string `json:"experiment_id"`
|
||
} `json:"experiment"`
|
||
} `json:"data"`
|
||
}
|
||
if err := json.Unmarshal(recorder.Body.Bytes(), &envelope); err != nil {
|
||
t.Fatalf("decode response: %v", err)
|
||
}
|
||
if envelope.Data.Experiment.ExperimentID != "lucky_exp_1" {
|
||
t.Fatalf("response=%s", recorder.Body.String())
|
||
}
|
||
}
|
||
|
||
func TestEndLuckyGiftExperimentReturnsExperimentAndFinalConfig(t *testing.T) {
|
||
gin.SetMode(gin.TestMode)
|
||
client := &experimentClientStub{endResponse: &luckygiftv1.EndLuckyGiftExperimentResponse{
|
||
Experiment: &luckygiftv1.LuckyGiftExperiment{
|
||
ExperimentId: "lucky_exp_1", Status: "completed", WinnerArm: "treatment",
|
||
WinnerRuleVersion: 10, FinalRuleVersion: 11,
|
||
},
|
||
FinalConfig: &luckygiftv1.LuckyGiftRuleConfig{PoolId: "super_lucky", RuleVersion: 11, TargetRtpPpm: 960_000},
|
||
}}
|
||
c, recorder := experimentTestContext(t, http.MethodPost, "/admin/ops-center/lucky-gifts/experiments/lucky_exp_1/end?app_code=lalu", `{"winner_arm": "Treatment"}`)
|
||
c.Params = gin.Params{{Key: "experimentId", Value: "lucky_exp_1"}}
|
||
New(client, time.Second, nil).EndLuckyGiftExperiment(c)
|
||
if recorder.Code != http.StatusOK {
|
||
t.Fatalf("status=%d body=%s", recorder.Code, recorder.Body.String())
|
||
}
|
||
if client.endRequest == nil || client.endRequest.GetWinnerArm() != "treatment" || client.endRequest.GetOperatorAdminId() != 7 {
|
||
t.Fatalf("owner request=%+v", client.endRequest)
|
||
}
|
||
var envelope struct {
|
||
Code int `json:"code"`
|
||
Data struct {
|
||
Experiment struct {
|
||
Status string `json:"status"`
|
||
WinnerArm string `json:"winner_arm"`
|
||
FinalRuleVersion int64 `json:"final_rule_version"`
|
||
} `json:"experiment"`
|
||
FinalConfig struct {
|
||
RuleVersion int64 `json:"rule_version"`
|
||
TargetRTPPercent float64 `json:"target_rtp_percent"`
|
||
} `json:"final_config"`
|
||
} `json:"data"`
|
||
}
|
||
if err := json.Unmarshal(recorder.Body.Bytes(), &envelope); err != nil {
|
||
t.Fatalf("decode response: %v", err)
|
||
}
|
||
if envelope.Code != 0 || envelope.Data.Experiment.Status != "completed" || envelope.Data.Experiment.WinnerArm != "treatment" || envelope.Data.Experiment.FinalRuleVersion != 11 {
|
||
t.Fatalf("experiment response=%s", recorder.Body.String())
|
||
}
|
||
if envelope.Data.FinalConfig.RuleVersion != 11 || envelope.Data.FinalConfig.TargetRTPPercent != 96 {
|
||
t.Fatalf("final config response=%s", recorder.Body.String())
|
||
}
|
||
}
|
||
|
||
func TestListLuckyGiftExperimentsRequiresAppCode(t *testing.T) {
|
||
gin.SetMode(gin.TestMode)
|
||
c, recorder := experimentTestContext(t, http.MethodGet, "/admin/ops-center/lucky-gifts/experiments", "")
|
||
New(&experimentClientStub{}, time.Second, nil).ListLuckyGiftExperiments(c)
|
||
if recorder.Code != http.StatusBadRequest {
|
||
t.Fatalf("status=%d want=400 body=%s", recorder.Code, recorder.Body.String())
|
||
}
|
||
}
|