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()) } }