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" "google.golang.org/grpc/codes" "google.golang.org/grpc/status" ) type poolAdjustmentClientStub struct { luckygiftadmin.Client request *luckygiftv1.AdjustLuckyGiftPoolBalanceRequest response *luckygiftv1.AdjustLuckyGiftPoolBalanceResponse err error } func (s *poolAdjustmentClientStub) AdjustLuckyGiftPoolBalance(_ context.Context, request *luckygiftv1.AdjustLuckyGiftPoolBalanceRequest) (*luckygiftv1.AdjustLuckyGiftPoolBalanceResponse, error) { s.request = request return s.response, s.err } func TestConfigToProtoConvertsBusinessUnitsToPPM(t *testing.T) { config := configToProto(configRequest{ PoolID: " lucky_100 ", Enabled: true, TargetRTPPercent: 95, PoolRatePercent: 96, SettlementWindowWager: 1_000_000, ControlBandPercent: 1, GiftPriceReference: 100, NoviceMaxEquivalentDraws: 2_000, NormalMaxEquivalentDraws: 20_000, Stages: []stageDTO{ { Stage: " novice ", Tiers: []tierDTO{ { TierID: " novice_0_3x ", Multiplier: 0.3, ProbabilityPercent: 22, Enabled: true, }, }, }, }, }) if config.GetPoolId() != "lucky_100" { t.Fatalf("pool id = %q, want lucky_100", config.GetPoolId()) } if config.GetTargetRtpPpm() != 950_000 { t.Fatalf("target rtp ppm = %d, want 950000", config.GetTargetRtpPpm()) } if config.GetPoolRatePpm() != 960_000 { t.Fatalf("pool rate ppm = %d, want 960000", config.GetPoolRatePpm()) } if config.GetControlBandPpm() != 10_000 { t.Fatalf("control band ppm = %d, want 10000", config.GetControlBandPpm()) } if config.GetNoviceMaxEquivalentDraws() != 2_000 || config.GetNormalMaxEquivalentDraws() != 20_000 { t.Fatalf("equivalent draw thresholds mismatch: novice=%d normal=%d", config.GetNoviceMaxEquivalentDraws(), config.GetNormalMaxEquivalentDraws()) } tier := config.GetStages()[0].GetTiers()[0] if tier.GetStage() != "novice" { t.Fatalf("tier stage = %q, want novice", tier.GetStage()) } if tier.GetTierId() != "novice_0_3x" { t.Fatalf("tier id = %q, want novice_0_3x", tier.GetTierId()) } if tier.GetMultiplierPpm() != 300_000 { t.Fatalf("multiplier ppm = %d, want 300000", tier.GetMultiplierPpm()) } if tier.GetBaseWeightPpm() != 220_000 { t.Fatalf("base weight ppm = %d, want 220000", tier.GetBaseWeightPpm()) } } func TestCreditLuckyGiftPoolBalanceForwardsAuditedOwnerCommand(t *testing.T) { gin.SetMode(gin.TestMode) client := &poolAdjustmentClientStub{response: &luckygiftv1.AdjustLuckyGiftPoolBalanceResponse{ Adjustment: &luckygiftv1.LuckyGiftPoolAdjustment{ AdjustmentId: "adjustment-1", AppCode: "aslan", PoolId: "lucky", StrategyVersion: "dynamic_v3", Direction: "in", AmountCoins: 100, Reason: "launch budget", OperatorAdminId: 7, BalanceBefore: 0, BalanceAfter: 100, }, Pool: &luckygiftv1.LuckyGiftPoolBalance{ AppCode: "aslan", PoolId: "lucky", StrategyVersion: "dynamic_v3", Balance: 100, ManualCreditTotal: 100, Materialized: true, }, }} recorder := httptest.NewRecorder() c, _ := gin.CreateTestContext(recorder) c.Request = httptest.NewRequest(http.MethodPost, "/admin/ops-center/lucky-gifts/pools/credit?app_code=aslan&pool_id=lucky&strategy_version=dynamic_v3", bytes.NewBufferString(`{"adjustment_id":"adjustment-1","amount_coins":100,"reason":"launch budget"}`)) c.Request.Header.Set("Content-Type", "application/json") c.Set(middleware.ContextUserID, uint(7)) c.Set(middleware.ContextRequestID, "request-1") New(client, time.Second, nil).CreditLuckyGiftPoolBalance(c) if recorder.Code != http.StatusOK { t.Fatalf("status=%d body=%s", recorder.Code, recorder.Body.String()) } if client.request == nil || client.request.GetDirection() != "in" || client.request.GetOperatorAdminId() != 7 || client.request.GetMeta().GetAppCode() != "aslan" || client.request.GetMeta().GetRequestId() != "request-1" { t.Fatalf("owner request=%+v", client.request) } var envelope struct { Code int `json:"code"` Data struct { Pool struct { StrategyVersion string `json:"strategy_version"` ManualCreditTotal int64 `json:"manual_credit_total"` } `json:"pool"` } `json:"data"` } if err := json.Unmarshal(recorder.Body.Bytes(), &envelope); err != nil { t.Fatalf("decode response: %v", err) } if envelope.Code != 0 || envelope.Data.Pool.StrategyVersion != "dynamic_v3" || envelope.Data.Pool.ManualCreditTotal != 100 { t.Fatalf("response=%s", recorder.Body.String()) } } func TestDebitLuckyGiftPoolBalanceMapsOwnerConflictToHTTP409(t *testing.T) { gin.SetMode(gin.TestMode) client := &poolAdjustmentClientStub{err: status.Error(codes.FailedPrecondition, "debit would cross reserve floor")} recorder := httptest.NewRecorder() c, _ := gin.CreateTestContext(recorder) c.Request = httptest.NewRequest(http.MethodPost, "/admin/ops-center/lucky-gifts/pools/debit?app_code=aslan&pool_id=lucky&strategy_version=dynamic_v3", bytes.NewBufferString(`{"adjustment_id":"adjustment-2","amount_coins":101,"reason":"return budget"}`)) c.Request.Header.Set("Content-Type", "application/json") c.Set(middleware.ContextUserID, uint(7)) New(client, time.Second, nil).DebitLuckyGiftPoolBalance(c) if recorder.Code != http.StatusConflict { t.Fatalf("status=%d want=409 body=%s", recorder.Code, recorder.Body.String()) } } func TestConfigToProtoGeneratesUniqueTierIDs(t *testing.T) { config := configToProto(configRequest{ PoolID: "lucky", Stages: []stageDTO{ { Stage: "advanced", Tiers: []tierDTO{ {TierID: "manual_one", Multiplier: 0, ProbabilityPercent: 50, Enabled: true}, {TierID: "manual_two", Multiplier: 5, ProbabilityPercent: 25, Enabled: true}, {TierID: "manual_two", Multiplier: 5, ProbabilityPercent: 25, Enabled: true}, {TierID: "manual_decimal", Multiplier: 0.5, ProbabilityPercent: 0}, }, }, }, }) tiers := config.GetStages()[0].GetTiers() got := []string{tiers[0].GetTierId(), tiers[1].GetTierId(), tiers[2].GetTierId(), tiers[3].GetTierId()} want := []string{"advanced_none", "advanced_5x", "advanced_5x_2", "advanced_0_5x"} for index := range want { if got[index] != want[index] { t.Fatalf("tier id[%d] = %q, want %q; all=%v", index, got[index], want[index], got) } } } func TestConfigFromProtoConvertsPPMToBusinessUnits(t *testing.T) { config := configFromProto(&luckygiftv1.LuckyGiftRuleConfig{ AppCode: "hyapp", PoolId: "lucky_100", RuleVersion: 7, Enabled: true, TargetRtpPpm: 950_000, PoolRatePpm: 960_000, SettlementWindowWager: 1_000_000, ControlBandPpm: 10_000, GiftPriceReference: 100, NoviceMaxEquivalentDraws: 2_000, NormalMaxEquivalentDraws: 20_000, CreatedByAdminId: 12, CreatedAtMs: 1_700_000_000_000, Stages: []*luckygiftv1.LuckyGiftRuleStage{ { Stage: "novice", Tiers: []*luckygiftv1.LuckyGiftRuleTier{ { TierId: "novice_0_3x", MultiplierPpm: 300_000, BaseWeightPpm: 220_000, Enabled: true, }, }, }, }, }) if config.TargetRTPPercent != 95 { t.Fatalf("target rtp percent = %v, want 95", config.TargetRTPPercent) } if config.PoolRatePercent != 96 { t.Fatalf("pool rate percent = %v, want 96", config.PoolRatePercent) } if config.ControlBandPercent != 1 { t.Fatalf("control band percent = %v, want 1", config.ControlBandPercent) } if config.NoviceMaxEquivalentDraws != 2_000 || config.NormalMaxEquivalentDraws != 20_000 { t.Fatalf("equivalent draw thresholds were not copied from proto: novice=%d normal=%d", config.NoviceMaxEquivalentDraws, config.NormalMaxEquivalentDraws) } if config.Stages[0].Tiers[0].Multiplier != 0.3 { t.Fatalf("multiplier = %v, want 0.3", config.Stages[0].Tiers[0].Multiplier) } if config.Stages[0].Tiers[0].ProbabilityPercent != 22 { t.Fatalf("probability percent = %v, want 22", config.Stages[0].Tiers[0].ProbabilityPercent) } } func TestDynamicConfigAdminMappingRoundTrip(t *testing.T) { protoConfig := configToProto(configRequest{ AppCode: " LALU ", PoolID: " lucky ", StrategyVersion: " DYNAMIC_V3 ", Enabled: true, TargetRTPPercent: 98, PoolRatePercent: 98, ProfitRatePercent: 1, AnchorRatePercent: 1, SettlementWindowWager: 1_000_000, ControlBandPercent: 3, GiftPriceReference: 100, InitialPoolCoins: 1_000_000, LossStreakGuarantee: 5, LowWatermarkCoins: 10_000_000, LowWaterNonzeroFactorPercent: 70, HighWatermarkCoins: 20_000_000, HighWaterNonzeroFactorPercent: 130, RechargeBoostWindowMS: 300_000, RechargeBoostFactorPercent: 110, JackpotMultipliers: []float64{200, 500, 1000}, JackpotGlobalRTPMaxPercent: 98, JackpotUserDayRTPMaxPercent: 96, JackpotUser72hRTPMaxPercent: 96, JackpotSpendThresholdCoins: 5_000, MaxJackpotHitsPerUserDay: 5, MaxSinglePayout: 10_000, UserHourlyPayoutCap: 20_000, UserDailyPayoutCap: 30_000, DeviceDailyPayoutCap: 40_000, RoomHourlyPayoutCap: 50_000, AnchorDailyPayoutCap: 60_000, Stages: []stageDTO{{ Stage: " normal ", MinRecharge7DCoins: 7_000, MinRecharge30DCoins: 30_000, Tiers: []tierDTO{{Multiplier: 1, ProbabilityPercent: 100, Enabled: true}}, }}, }) if protoConfig.GetStrategyVersion() != "dynamic_v3" || protoConfig.GetProfitRatePpm() != 10_000 || protoConfig.GetAnchorRatePpm() != 10_000 { t.Fatalf("strategy/fund split proto mismatch: %+v", protoConfig) } if protoConfig.GetInitialPoolCoins() != 0 { t.Fatalf("dynamic_v3 legacy initial pool was forwarded: %d", protoConfig.GetInitialPoolCoins()) } if protoConfig.GetLowWaterNonzeroFactorPpm() != 700_000 || protoConfig.GetHighWaterNonzeroFactorPpm() != 1_300_000 || protoConfig.GetRechargeBoostFactorPpm() != 1_100_000 { t.Fatalf("dynamic factors lost precision in admin mapping: %+v", protoConfig) } if got := protoConfig.GetJackpotMultiplierPpms(); len(got) != 3 || got[0] != 200_000_000 || got[2] != 1_000_000_000 { t.Fatalf("jackpot multiplier ppms=%v, want 200x/500x/1000x", got) } stage := protoConfig.GetStages()[0] if stage.GetMinRecharge_7DCoins() != 7_000 || stage.GetMinRecharge_30DCoins() != 30_000 { t.Fatalf("stage recharge thresholds were not mapped: %+v", stage) } dto := configFromProto(protoConfig) if dto.StrategyVersion != "dynamic_v3" || dto.ProfitRatePercent != 1 || dto.AnchorRatePercent != 1 { t.Fatalf("strategy/fund split DTO mismatch: %+v", dto) } if dto.InitialPoolCoins != 0 { t.Fatalf("dynamic_v3 DTO exposed legacy initial pool: %d", dto.InitialPoolCoins) } if dto.LowWaterNonzeroFactorPercent != 70 || dto.HighWaterNonzeroFactorPercent != 130 || dto.RechargeBoostFactorPercent != 110 { t.Fatalf("dynamic factor DTO mismatch: %+v", dto) } if len(dto.JackpotMultipliers) != 3 || dto.JackpotMultipliers[1] != 500 || dto.JackpotUser72hRTPMaxPercent != 96 { t.Fatalf("jackpot DTO mismatch: %+v", dto) } if dto.MaxSinglePayout != 10_000 || dto.AnchorDailyPayoutCap != 60_000 || dto.Stages[0].MinRecharge30DCoins != 30_000 { t.Fatalf("risk/stage DTO mismatch: %+v", dto) } }