package draw import ( "context" "database/sql" "database/sql/driver" "encoding/json" "errors" "fmt" "strings" "sync" "testing" "time" "github.com/DATA-DOG/go-sqlmock" "github.com/perfect-panel/server/internal/model/lottery" "github.com/perfect-panel/server/pkg/xerr" "gorm.io/driver/mysql" "gorm.io/gorm" ) // ---- test fakes ------------------------------------------------------------ type fakeRateLimiter struct { mu sync.Mutex err error calls int } func (f *fakeRateLimiter) Allow(_ context.Context, _ int64) error { f.mu.Lock() defer f.mu.Unlock() f.calls++ return f.err } type fakeChance struct { mu sync.Mutex consumeRemaining int64 consumeErr error consumeCalls int queryRemaining int64 } func (f *fakeChance) Grant(context.Context, int64, int64, string, string, int) error { return nil } func (f *fakeChance) Consume(_ context.Context, _ *gorm.DB, _, _ int64) (int64, error) { f.mu.Lock() defer f.mu.Unlock() f.consumeCalls++ if f.consumeErr != nil { return 0, f.consumeErr } return f.consumeRemaining, nil } func (f *fakeChance) Query(_ context.Context, _, _ int64) (int64, error) { return f.queryRemaining, nil } type fakeEvaluator struct { passed bool unmet []lottery.UnmetReason err error } func (f *fakeEvaluator) Evaluate(context.Context, *lottery.EligibilityRule, lottery.RuleContext) (bool, []lottery.UnmetReason, error) { return f.passed, f.unmet, f.err } type fakePicker struct { idx int err error } func (f *fakePicker) Pick([]lottery.Prize) (int, error) { return f.idx, f.err } type fakeContextBuilder struct{} func (fakeContextBuilder) Build(_ context.Context, uid int64) (lottery.RuleContext, error) { return lottery.RuleContext{UserId: uid}, nil } type recordingHandler struct { handlerType string auto bool result lottery.DispatchResult err error calls int } func (h *recordingHandler) Type() string { return h.handlerType } func (h *recordingHandler) IsAuto() bool { return h.auto } func (h *recordingHandler) Dispatch(_ context.Context, _ *gorm.DB, _ lottery.DispatchRequest) (lottery.DispatchResult, error) { h.calls++ return h.result, h.err } func (h *recordingHandler) ValidateClaim([]byte) error { return nil } func (h *recordingHandler) ClaimSchema() json.RawMessage { return nil } // stubRegistry only knows what we register. type stubRegistry struct { handlers map[string]lottery.PrizeHandler } func (r *stubRegistry) Get(t string) (lottery.PrizeHandler, bool) { h, ok := r.handlers[t] return h, ok } func (r *stubRegistry) MustGet(t string) (lottery.PrizeHandler, error) { if h, ok := r.handlers[t]; ok { return h, nil } return nil, lottery.ErrHandlerNotRegistered } // ---- shared harness -------------------------------------------------------- func newTestDB(t *testing.T) (*gorm.DB, sqlmock.Sqlmock, func()) { t.Helper() sqlDB, mock, err := sqlmock.New(sqlmock.QueryMatcherOption(sqlmock.QueryMatcherFunc(func(expected, actual string) error { if strings.Contains(actual, expected) { return nil } return fmt.Errorf("actual sql %q does not contain %q", actual, expected) }))) if err != nil { t.Fatalf("sqlmock: %v", err) } db, err := gorm.Open(mysql.New(mysql.Config{Conn: sqlDB, SkipInitializeWithVersion: true}), &gorm.Config{}) if err != nil { _ = sqlDB.Close() t.Fatalf("gorm: %v", err) } return db, mock, func() { _ = sqlDB.Close() } } // expectRunningActivity sets up the pre-tx activity load. func expectRunningActivity(mock sqlmock.Sqlmock, activityId int64) { now := time.Now() mock.ExpectQuery("FROM `lottery_activity`"). WillReturnRows(sqlmock.NewRows([]string{ "id", "title", "start_at", "end_at", "status", "grid_size", "eligibility", "chance_sources", "unmet_action", }).AddRow(activityId, "test", now.Add(-1*time.Hour), now.Add(24*time.Hour), lottery.ActivityStatusRunning, 9, "{}", "[]", "block")) } // expectPrizePool sets up the pre-tx prize load. func expectPrizePool(mock sqlmock.Sqlmock, activityId int64, prizes ...lottery.Prize) { rows := sqlmock.NewRows([]string{"id", "activity_id", "slot", "type", "name", "icon_url", "config", "weight", "total_stock", "remaining_stock", "is_fallback", "version"}) for _, p := range prizes { rows.AddRow(p.Id, p.ActivityId, p.Slot, p.Type, p.Name, p.IconURL, p.Config, p.Weight, p.TotalStock, p.RemainingStock, p.IsFallback, p.Version) } mock.ExpectQuery("FROM `lottery_prize`").WillReturnRows(rows) } // expectExistingDrawEmpty sets up the tx-inner nonce lookup returning no rows. func expectExistingDrawEmpty(mock sqlmock.Sqlmock) { mock.ExpectQuery("FROM `lottery_draw`").WillReturnError(gorm.ErrRecordNotFound) } // ---- Test cases ------------------------------------------------------------ func TestDraw_RejectsWhenFeatureFlagDisabled(t *testing.T) { db, _, cleanup := newTestDB(t) defer cleanup() svc := NewService(Deps{ DB: db, Enabled: false, }) _, err := svc.Draw(context.Background(), Request{UserId: 42, ActivityId: 100, ClientNonce: "n1"}) code, ok := errAsCode(err) if !ok || code != xerr.LotteryActivityEnded { t.Fatalf("expected LotteryActivityEnded when disabled, got %v", err) } } func TestDraw_ValidatesRequest(t *testing.T) { db, _, cleanup := newTestDB(t) defer cleanup() svc := NewService(Deps{DB: db, Enabled: true}) cases := []Request{ {UserId: 0, ActivityId: 1, ClientNonce: "n"}, {UserId: 1, ActivityId: 0, ClientNonce: "n"}, {UserId: 1, ActivityId: 1, ClientNonce: ""}, {UserId: 1, ActivityId: 1, ClientNonce: strings.Repeat("x", 65)}, } for i, c := range cases { if _, err := svc.Draw(context.Background(), c); err == nil { t.Fatalf("case %d expected error, got nil", i) } } } func TestDraw_RateLimited(t *testing.T) { db, _, cleanup := newTestDB(t) defer cleanup() limiter := &fakeRateLimiter{err: ErrRateLimited} svc := NewService(Deps{DB: db, Enabled: true, RateLimiter: limiter}) _, err := svc.Draw(context.Background(), Request{UserId: 1, ActivityId: 1, ClientNonce: "n"}) code, ok := errAsCode(err) if !ok || code != xerr.LotteryRateLimited { t.Fatalf("expected LotteryRateLimited, got %v", err) } } func TestDraw_NonceIdempotency_ReturnsExisting(t *testing.T) { db, mock, cleanup := newTestDB(t) defer cleanup() expectRunningActivity(mock, 100) expectPrizePool(mock, 100, lottery.Prize{Id: 1, ActivityId: 100, Slot: 0, Type: lottery.PrizeTypeNone, Name: "谢谢参与", Config: "{}", Weight: 1}) mock.ExpectBegin() // nonce hit — the flow short-circuits mock.ExpectQuery("FROM `lottery_draw`"). WillReturnRows(sqlmock.NewRows([]string{"id", "user_id", "activity_id", "client_nonce", "prize_id", "is_win", "dispatch_state", "drawn_at"}). AddRow(int64(999), int64(42), int64(100), "same-nonce", nil, false, lottery.DispatchStateAutoClaimed, time.Now())) // snapshot lookup mock.ExpectQuery("FROM `lottery_prize_snapshot`"). WillReturnRows(sqlmock.NewRows([]string{"id", "draw_id", "prize_id", "slot", "type", "name", "config"}). AddRow(int64(1), int64(999), int64(0), 0, lottery.PrizeTypeNone, "谢谢参与", "{}")) mock.ExpectCommit() chance := &fakeChance{queryRemaining: 3} svc := NewService(Deps{ DB: db, Enabled: true, Chance: chance, Evaluator: &fakeEvaluator{passed: true}, Picker: &fakePicker{idx: 0}, Registry: &stubRegistry{handlers: map[string]lottery.PrizeHandler{}}, ContextBuilder: fakeContextBuilder{}, }) res, err := svc.Draw(context.Background(), Request{UserId: 42, ActivityId: 100, ClientNonce: "same-nonce"}) if err != nil { t.Fatalf("Draw: %v", err) } if res.DrawId != 999 { t.Fatalf("expected reuse existing draw id=999, got %d", res.DrawId) } if chance.consumeCalls != 0 { t.Fatalf("must NOT Consume a chance on nonce replay") } } func TestDraw_NoChancesReturnsCode(t *testing.T) { db, mock, cleanup := newTestDB(t) defer cleanup() expectRunningActivity(mock, 100) expectPrizePool(mock, 100, lottery.Prize{Id: 1, ActivityId: 100, Slot: 0, Type: lottery.PrizeTypeNone, Name: "谢谢参与", Config: "{}", Weight: 1}) mock.ExpectBegin() expectExistingDrawEmpty(mock) mock.ExpectRollback() chance := &fakeChance{consumeErr: lottery.ErrNoChances} svc := NewService(Deps{ DB: db, Enabled: true, Chance: chance, Evaluator: &fakeEvaluator{passed: true}, Picker: &fakePicker{idx: 0}, Registry: &stubRegistry{handlers: map[string]lottery.PrizeHandler{}}, ContextBuilder: fakeContextBuilder{}, }) _, err := svc.Draw(context.Background(), Request{UserId: 1, ActivityId: 100, ClientNonce: "n"}) code, ok := errAsCode(err) if !ok || code != xerr.LotteryNoChances { t.Fatalf("expected LotteryNoChances, got %v", err) } } func TestDraw_NotEligibleRejectsBeforeConsume(t *testing.T) { db, mock, cleanup := newTestDB(t) defer cleanup() expectRunningActivity(mock, 100) svc := NewService(Deps{ DB: db, Enabled: true, Chance: &fakeChance{}, Evaluator: &fakeEvaluator{passed: false, unmet: []lottery.UnmetReason{{Rule: "invite_count", Hint: "need 3"}}}, Picker: &fakePicker{}, Registry: &stubRegistry{}, ContextBuilder: fakeContextBuilder{}, }) _, err := svc.Draw(context.Background(), Request{UserId: 1, ActivityId: 100, ClientNonce: "n"}) code, ok := errAsCode(err) if !ok || code != xerr.LotteryNotEligible { t.Fatalf("expected LotteryNotEligible, got %v", err) } // Ensure no mock expectations remain (we didn't set up prize load). if err := mock.ExpectationsWereMet(); err != nil { t.Fatalf("expectations: %v", err) } } func TestDraw_SuccessAutoClaimedNoneReturnsDrawWithoutDispatch(t *testing.T) { db, mock, cleanup := newTestDB(t) defer cleanup() expectRunningActivity(mock, 100) // Two prizes: one none (weight 100) — picker returns idx 0 always expectPrizePool(mock, 100, lottery.Prize{Id: 1, ActivityId: 100, Slot: 0, Type: lottery.PrizeTypeNone, Name: "谢谢参与", Config: "{}", Weight: 100}, ) mock.ExpectBegin() expectExistingDrawEmpty(mock) // insert draw mock.ExpectExec("INSERT INTO `lottery_draw`"). WillReturnResult(sqlmock.NewResult(555, 1)) // insert prize_snapshot mock.ExpectExec("INSERT INTO `lottery_prize_snapshot`"). WillReturnResult(sqlmock.NewResult(1, 1)) // insert eligibility_snapshot mock.ExpectExec("INSERT INTO `lottery_eligibility_snapshot`"). WillReturnResult(sqlmock.NewResult(1, 1)) // finalize draw state mock.ExpectExec("UPDATE `lottery_draw`"). WillReturnResult(sqlmock.NewResult(0, 1)) mock.ExpectCommit() chance := &fakeChance{consumeRemaining: 2} svc := NewService(Deps{ DB: db, Enabled: true, Chance: chance, Evaluator: &fakeEvaluator{passed: true}, Picker: &fakePicker{idx: 0}, Registry: &stubRegistry{handlers: map[string]lottery.PrizeHandler{}}, ContextBuilder: fakeContextBuilder{}, }) res, err := svc.Draw(context.Background(), Request{UserId: 1, ActivityId: 100, ClientNonce: "unique-1"}) if err != nil { t.Fatalf("Draw: %v", err) } if res.IsWin { t.Fatalf("none prize should not count as win") } if res.DrawId == 0 { t.Fatalf("expected draw id from LastInsertId") } if res.ChancesRemaining != 2 { t.Fatalf("expected remaining=2 from Consume, got %d", res.ChancesRemaining) } } func TestDraw_LimitedStockFallback(t *testing.T) { db, mock, cleanup := newTestDB(t) defer cleanup() expectRunningActivity(mock, 100) // prize 0 = limited (remaining=0 → sold out); prize 1 = fallback expectPrizePool(mock, 100, lottery.Prize{Id: 10, ActivityId: 100, Slot: 0, Type: "vpn_duration", Name: "3天", Config: `{"duration_days":3}`, Weight: 100, TotalStock: sql.NullInt64{Int64: 1, Valid: true}, RemainingStock: sql.NullInt64{Int64: 1, Valid: true}}, lottery.Prize{Id: 20, ActivityId: 100, Slot: 1, Type: lottery.PrizeTypeNone, Name: "谢谢参与", Config: "{}", Weight: 0, IsFallback: true}, ) mock.ExpectBegin() expectExistingDrawEmpty(mock) // stock decrement returns 0 rows affected → sold out mock.ExpectExec("UPDATE `lottery_prize`"). WillReturnResult(sqlmock.NewResult(0, 0)) mock.ExpectExec("INSERT INTO `lottery_draw`"). WillReturnResult(sqlmock.NewResult(778, 1)) mock.ExpectExec("INSERT INTO `lottery_prize_snapshot`"). WillReturnResult(sqlmock.NewResult(1, 1)) mock.ExpectExec("INSERT INTO `lottery_eligibility_snapshot`"). WillReturnResult(sqlmock.NewResult(1, 1)) mock.ExpectExec("UPDATE `lottery_draw`"). WillReturnResult(sqlmock.NewResult(0, 1)) mock.ExpectCommit() svc := NewService(Deps{ DB: db, Enabled: true, Chance: &fakeChance{consumeRemaining: 1}, Evaluator: &fakeEvaluator{passed: true}, Picker: &fakePicker{idx: 0}, Registry: &stubRegistry{handlers: map[string]lottery.PrizeHandler{}}, ContextBuilder: fakeContextBuilder{}, }) res, err := svc.Draw(context.Background(), Request{UserId: 1, ActivityId: 100, ClientNonce: "n"}) if err != nil { t.Fatalf("Draw: %v", err) } if res.IsWin { t.Fatalf("fallback none should not win") } if res.DrawId != 778 { t.Fatalf("expected draw id 778, got %d", res.DrawId) } } func TestDraw_WinCallsAutoHandlerDispatch(t *testing.T) { db, mock, cleanup := newTestDB(t) defer cleanup() expectRunningActivity(mock, 100) expectPrizePool(mock, 100, lottery.Prize{Id: 30, ActivityId: 100, Slot: 5, Type: lottery.PrizeTypeVPNDuration, Name: "3 天", Config: `{"duration_days":3}`, Weight: 100}, ) handler := &recordingHandler{ handlerType: lottery.PrizeTypeVPNDuration, auto: true, result: lottery.DispatchResult{State: lottery.DispatchStateAutoClaimed, Message: "已加 3 天"}, } mock.ExpectBegin() expectExistingDrawEmpty(mock) mock.ExpectExec("INSERT INTO `lottery_draw`"). WillReturnResult(sqlmock.NewResult(1234, 1)) mock.ExpectExec("INSERT INTO `lottery_prize_snapshot`"). WillReturnResult(sqlmock.NewResult(1, 1)) mock.ExpectExec("INSERT INTO `lottery_eligibility_snapshot`"). WillReturnResult(sqlmock.NewResult(1, 1)) mock.ExpectExec("UPDATE `lottery_draw`"). WillReturnResult(sqlmock.NewResult(0, 1)) mock.ExpectCommit() svc := NewService(Deps{ DB: db, Enabled: true, Chance: &fakeChance{consumeRemaining: 0}, Evaluator: &fakeEvaluator{passed: true}, Picker: &fakePicker{idx: 0}, Registry: &stubRegistry{handlers: map[string]lottery.PrizeHandler{lottery.PrizeTypeVPNDuration: handler}}, ContextBuilder: fakeContextBuilder{}, }) res, err := svc.Draw(context.Background(), Request{UserId: 1, ActivityId: 100, ClientNonce: "w"}) if err != nil { t.Fatalf("Draw: %v", err) } if !res.IsWin { t.Fatalf("expected IsWin=true for vpn_duration") } if handler.calls != 1 { t.Fatalf("expected handler.Dispatch called once, got %d", handler.calls) } if res.Claim.AutoClaimed != true { t.Fatalf("expected AutoClaimed=true, got %+v", res.Claim) } if res.Message != "已加 3 天" { t.Fatalf("expected message from Dispatch, got %q", res.Message) } if res.Prize == nil || res.Prize.Type != lottery.PrizeTypeVPNDuration { t.Fatalf("expected prize summary, got %+v", res.Prize) } // Prize.Config should be embedded json.RawMessage — verify decodes var cfg map[string]any if err := json.Unmarshal(res.Prize.Config, &cfg); err != nil { t.Fatalf("Prize.Config invalid: %v", err) } if cfg["duration_days"].(float64) != 3 { t.Fatalf("expected duration_days=3, got %v", cfg["duration_days"]) } } func TestDraw_UnregisteredAutoHandlerFallsBackToPendingClaim(t *testing.T) { db, mock, cleanup := newTestDB(t) defer cleanup() expectRunningActivity(mock, 100) expectPrizePool(mock, 100, lottery.Prize{Id: 40, ActivityId: 100, Slot: 0, Type: "encrypted", Name: "Encrypted", Config: "{}", Weight: 100}, ) mock.ExpectBegin() expectExistingDrawEmpty(mock) mock.ExpectExec("INSERT INTO `lottery_draw`"). WillReturnResult(sqlmock.NewResult(1, 1)) mock.ExpectExec("INSERT INTO `lottery_prize_snapshot`"). WillReturnResult(sqlmock.NewResult(1, 1)) mock.ExpectExec("INSERT INTO `lottery_eligibility_snapshot`"). WillReturnResult(sqlmock.NewResult(1, 1)) mock.ExpectExec("UPDATE `lottery_draw`"). WillReturnResult(sqlmock.NewResult(0, 1)) mock.ExpectCommit() svc := NewService(Deps{ DB: db, Enabled: true, Chance: &fakeChance{consumeRemaining: 0}, Evaluator: &fakeEvaluator{passed: true}, Picker: &fakePicker{idx: 0}, Registry: &stubRegistry{handlers: map[string]lottery.PrizeHandler{}}, ContextBuilder: fakeContextBuilder{}, }) res, err := svc.Draw(context.Background(), Request{UserId: 1, ActivityId: 100, ClientNonce: "w2"}) if err != nil { t.Fatalf("Draw: %v", err) } if res.Claim.Required != true { t.Fatalf("expected Claim.Required=true when handler unregistered, got %+v", res.Claim) } } // errAsCode helps assert xerr.CodeError codes. func errAsCode(err error) (uint32, bool) { if err == nil { return 0, false } var ce *xerr.CodeError if errors.As(err, &ce) { return ce.GetErrCode(), true } return 0, false } // TestInsertSnapshots_UnmetReasonsIsValidJSON is the F4 regression guard // (kept from PR E — must survive Stage 2 rebase). // // Before PR E, insertSnapshots created lottery_eligibility_snapshot rows with // UnmetReasons="" — MySQL error 3140 rejects empty strings on JSON columns, // so every real /draw request 100% failed the tx commit even though sqlmock // (which does no JSON validation) was happy. This test snapshots the exact // INSERT arg values and asserts: // 1. UnmetReasons must never be "" (it should be "[]") // 2. EvaluatedAt must not be the zero time.Time (STRICT sql_mode rejects // '0000-00-00 00:00:00' on DATETIME NOT NULL) // // sqlmock cannot catch the JSON validity itself — only real MySQL can — but // it can catch the two upstream bugs that let bad values through the Go // layer. This is a defense-in-depth check. func TestInsertSnapshots_UnmetReasonsIsValidJSON(t *testing.T) { db, mock, cleanup := newTestDB(t) defer cleanup() expectRunningActivity(mock, 100) expectPrizePool(mock, 100, lottery.Prize{ Id: 1, ActivityId: 100, Slot: 0, Type: lottery.PrizeTypeNone, Name: "谢谢参与", Config: "{}", Weight: 100, }) mock.ExpectBegin() expectExistingDrawEmpty(mock) mock.ExpectExec("INSERT INTO `lottery_draw`"). WillReturnResult(sqlmock.NewResult(555, 1)) mock.ExpectExec("INSERT INTO `lottery_prize_snapshot`"). WillReturnResult(sqlmock.NewResult(1, 1)) // Field order matches EligibilitySnapshot struct-tag order under GORM: // draw_id, user_id, activity_id, passed, unmet_reasons, evaluated_at. // We assert UnmetReasons=="[]" (never "") and EvaluatedAt is non-zero. mock.ExpectExec("INSERT INTO `lottery_eligibility_snapshot`"). WithArgs( sqlmock.AnyArg(), // draw_id sqlmock.AnyArg(), // user_id sqlmock.AnyArg(), // activity_id sqlmock.AnyArg(), // passed unmetReasonsNotEmpty{t}, // MUST be "[]" evaluatedAtNotZero{t}, // MUST be non-zero time ). WillReturnResult(sqlmock.NewResult(1, 1)) mock.ExpectExec("UPDATE `lottery_draw`"). WillReturnResult(sqlmock.NewResult(0, 1)) mock.ExpectCommit() svc := NewService(Deps{ DB: db, Enabled: true, Chance: &fakeChance{consumeRemaining: 2}, Evaluator: &fakeEvaluator{passed: true}, Picker: &fakePicker{idx: 0}, Registry: &stubRegistry{handlers: map[string]lottery.PrizeHandler{}}, ContextBuilder: fakeContextBuilder{}, }) _, err := svc.Draw(context.Background(), Request{UserId: 1, ActivityId: 100, ClientNonce: "f4-regression"}) if err != nil { t.Fatalf("Draw: %v", err) } if err := mock.ExpectationsWereMet(); err != nil { t.Fatalf("expectations: %v", err) } } // unmetReasonsNotEmpty is a per-arg matcher: the value MUST be the string // "[]"; the empty string is the exact F4 regression we are guarding against. type unmetReasonsNotEmpty struct{ t *testing.T } func (m unmetReasonsNotEmpty) Match(v driver.Value) bool { s, ok := v.(string) if !ok { m.t.Fatalf("F4 guard: expected string for UnmetReasons, got %T (%v)", v, v) } if s == "" { m.t.Fatalf("F4 regression: UnmetReasons must not be empty string (MySQL error 3140)") } if s != "[]" { m.t.Fatalf("F4 guard: expected UnmetReasons==%q, got %q", "[]", s) } return true } // evaluatedAtNotZero is a per-arg matcher: the value MUST be a non-zero // time.Time; the zero time is the F5 regression that STRICT sql_mode rejects // as '0000-00-00 00:00:00'. type evaluatedAtNotZero struct{ t *testing.T } func (m evaluatedAtNotZero) Match(v driver.Value) bool { tv, ok := v.(time.Time) if !ok { m.t.Fatalf("F5 guard: expected time.Time for EvaluatedAt, got %T (%v)", v, v) } if tv.IsZero() { m.t.Fatalf("F5 regression: EvaluatedAt must not be zero time") } return true } // ---- Stage 2 (manual claim) tests ---------------------------------------- // TestDraw_ManualClaimHandlerInsertsPendingClaim 验证:命中 IsAuto()==false // 的 handler 时,draw 事务里会 INSERT lottery_claim 并返回 ExpiresAt + Schema。 // 复用 PR E 的 F4/F5 matcher 断言 EligibilitySnapshot 守卫在人工奖分支同样生效。 func TestDraw_ManualClaimHandlerInsertsPendingClaim(t *testing.T) { db, mock, cleanup := newTestDB(t) defer cleanup() expectRunningActivity(mock, 100) expectPrizePool(mock, 100, lottery.Prize{ Id: 50, ActivityId: 100, Slot: 3, Type: lottery.PrizeTypeCrypto, Name: "1 BTC", Config: `{"amount":"1","currency":"BTC","networks":["BTC","TRX"],"claim_ttl_hours":48}`, Weight: 100, }, ) manualHandler := &recordingHandler{ handlerType: lottery.PrizeTypeCrypto, auto: false, } mock.ExpectBegin() expectExistingDrawEmpty(mock) mock.ExpectExec("INSERT INTO `lottery_draw`"). WillReturnResult(sqlmock.NewResult(5678, 1)) mock.ExpectExec("INSERT INTO `lottery_prize_snapshot`"). WillReturnResult(sqlmock.NewResult(1, 1)) // F4/F5 regression guards MUST hold on manual-claim path too. mock.ExpectExec("INSERT INTO `lottery_eligibility_snapshot`"). WithArgs( sqlmock.AnyArg(), // draw_id sqlmock.AnyArg(), // user_id sqlmock.AnyArg(), // activity_id sqlmock.AnyArg(), // passed unmetReasonsNotEmpty{t}, // MUST be "[]" evaluatedAtNotZero{t}, // MUST be non-zero time ). WillReturnResult(sqlmock.NewResult(1, 1)) // pending_claim row insert mock.ExpectExec("INSERT INTO `lottery_claim`"). WillReturnResult(sqlmock.NewResult(1, 1)) // finalize draw.dispatch_state = pending_claim mock.ExpectExec("UPDATE `lottery_draw`"). WillReturnResult(sqlmock.NewResult(0, 1)) mock.ExpectCommit() svc := NewService(Deps{ DB: db, Enabled: true, Chance: &fakeChance{consumeRemaining: 0}, Evaluator: &fakeEvaluator{passed: true}, Picker: &fakePicker{idx: 0}, Registry: &stubRegistry{handlers: map[string]lottery.PrizeHandler{lottery.PrizeTypeCrypto: manualHandler}}, ContextBuilder: fakeContextBuilder{}, }) res, err := svc.Draw(context.Background(), Request{UserId: 1, ActivityId: 100, ClientNonce: "manual1"}) if err != nil { t.Fatalf("Draw: %v", err) } if manualHandler.calls != 0 { t.Fatalf("manual handler.Dispatch must NOT be called, got %d calls", manualHandler.calls) } if !res.Claim.Required { t.Fatalf("expected Claim.Required=true, got %+v", res.Claim) } if res.Claim.AutoClaimed { t.Fatalf("expected AutoClaimed=false for manual, got %+v", res.Claim) } if res.Claim.ExpiresAt == 0 { t.Fatal("expected non-zero ExpiresAt") } // 48h TTL from prize config expected := time.Now().Add(48 * time.Hour).Unix() if diff := res.Claim.ExpiresAt - expected; diff > 5 || diff < -5 { t.Fatalf("ExpiresAt off by %ds; got %d expected ~%d", diff, res.Claim.ExpiresAt, expected) } // crypto handler builds schema with enum injected from prize config if len(res.Claim.ClaimFormSchema) == 0 { t.Fatal("expected ClaimFormSchema for crypto") } if !strings.Contains(string(res.Claim.ClaimFormSchema), `"enum":["BTC","TRX"]`) { t.Fatalf("expected enum with BTC/TRX in schema, got %s", res.Claim.ClaimFormSchema) } if err := mock.ExpectationsWereMet(); err != nil { t.Fatalf("expectations: %v", err) } } // TestDraw_ManualClaimDefaultsTo7DayTTL 验证:奖品 config 没写 claim_ttl_hours // 时,落在默认 168h(7 天)窗口。 func TestDraw_ManualClaimDefaultsTo7DayTTL(t *testing.T) { db, mock, cleanup := newTestDB(t) defer cleanup() expectRunningActivity(mock, 100) expectPrizePool(mock, 100, lottery.Prize{ Id: 60, ActivityId: 100, Slot: 4, Type: lottery.PrizeTypePhysical, Name: "T-shirt", Config: `{"sku_id":"tee-01","sku_name":"限量 T 恤"}`, Weight: 100, }, ) mock.ExpectBegin() expectExistingDrawEmpty(mock) mock.ExpectExec("INSERT INTO `lottery_draw`"). WillReturnResult(sqlmock.NewResult(9001, 1)) mock.ExpectExec("INSERT INTO `lottery_prize_snapshot`"). WillReturnResult(sqlmock.NewResult(1, 1)) mock.ExpectExec("INSERT INTO `lottery_eligibility_snapshot`"). WithArgs( sqlmock.AnyArg(), sqlmock.AnyArg(), sqlmock.AnyArg(), sqlmock.AnyArg(), unmetReasonsNotEmpty{t}, // F4 guard also applies here evaluatedAtNotZero{t}, // F5 guard also applies here ). WillReturnResult(sqlmock.NewResult(1, 1)) mock.ExpectExec("INSERT INTO `lottery_claim`"). WillReturnResult(sqlmock.NewResult(1, 1)) mock.ExpectExec("UPDATE `lottery_draw`"). WillReturnResult(sqlmock.NewResult(0, 1)) mock.ExpectCommit() svc := NewService(Deps{ DB: db, Enabled: true, Chance: &fakeChance{consumeRemaining: 0}, Evaluator: &fakeEvaluator{passed: true}, Picker: &fakePicker{idx: 0}, Registry: &stubRegistry{handlers: map[string]lottery.PrizeHandler{ lottery.PrizeTypePhysical: &recordingHandler{handlerType: lottery.PrizeTypePhysical, auto: false}, }}, ContextBuilder: fakeContextBuilder{}, }) res, err := svc.Draw(context.Background(), Request{UserId: 2, ActivityId: 100, ClientNonce: "manual2"}) if err != nil { t.Fatalf("Draw: %v", err) } expected := time.Now().Add(time.Duration(lottery.DefaultClaimTTLHours) * time.Hour).Unix() if diff := res.Claim.ExpiresAt - expected; diff > 5 || diff < -5 { t.Fatalf("expected default 7-day TTL, got diff=%ds", diff) } } // TestDraw_ManualClaimClaimDataIsValidJSON is the F7 regression guard // (HIF-11): the Stage 2 manual-claim path inserts a lottery_claim row inside // the draw tx; Claim.ClaimData is declared `gorm:"type:json"` and MySQL error // 3140 rejects the Go zero value "" on JSON columns. Same class of bug as // PR E (UnmetReasons="[]") and PR F (GrantLedger.Payload="{}"). // // The regression before the fix: 100% of manual-prize draws (crypto / // physical / manual_other) hit `100500 insert lottery_claim for draw N: // Error 3140 (22032): Invalid JSON text: "The document is empty."`. // // sqlmock does no JSON validation, but this per-arg matcher snapshots the // exact ClaimData bind value and fails if it's the empty string — the exact // Go-layer bug real MySQL would reject downstream. func TestDraw_ManualClaimClaimDataIsValidJSON(t *testing.T) { db, mock, cleanup := newTestDB(t) defer cleanup() expectRunningActivity(mock, 100) expectPrizePool(mock, 100, lottery.Prize{ Id: 70, ActivityId: 100, Slot: 5, Type: lottery.PrizeTypeManualOther, Name: "定制手办", Config: `{"sku_name":"定制"}`, Weight: 100, }) mock.ExpectBegin() expectExistingDrawEmpty(mock) mock.ExpectExec("INSERT INTO `lottery_draw`"). WillReturnResult(sqlmock.NewResult(7777, 1)) mock.ExpectExec("INSERT INTO `lottery_prize_snapshot`"). WillReturnResult(sqlmock.NewResult(1, 1)) mock.ExpectExec("INSERT INTO `lottery_eligibility_snapshot`"). WithArgs( sqlmock.AnyArg(), sqlmock.AnyArg(), sqlmock.AnyArg(), sqlmock.AnyArg(), unmetReasonsNotEmpty{t}, // F4 guard survives evaluatedAtNotZero{t}, // F5 guard survives ). WillReturnResult(sqlmock.NewResult(1, 1)) // Actual GORM bind order (nullable *time.Time fields — SubmittedAt, // ReviewedAt, PaidAt — are elided when nil): // draw_id, user_id, activity_id, prize_type, claim_data, status, // expires_at, reviewed_by, reject_reason, tx_hash, delivery_ref, // created_at, updated_at. // Assert ClaimData (position 5) is valid JSON — never "". mock.ExpectExec("INSERT INTO `lottery_claim`"). WithArgs( sqlmock.AnyArg(), // draw_id sqlmock.AnyArg(), // user_id sqlmock.AnyArg(), // activity_id sqlmock.AnyArg(), // prize_type claimDataIsValidJSON{t}, // MUST be valid JSON, not "" sqlmock.AnyArg(), // status sqlmock.AnyArg(), // expires_at sqlmock.AnyArg(), // reviewed_by sqlmock.AnyArg(), // reject_reason sqlmock.AnyArg(), // tx_hash sqlmock.AnyArg(), // delivery_ref sqlmock.AnyArg(), // created_at sqlmock.AnyArg(), // updated_at ). WillReturnResult(sqlmock.NewResult(1, 1)) mock.ExpectExec("UPDATE `lottery_draw`"). WillReturnResult(sqlmock.NewResult(0, 1)) mock.ExpectCommit() svc := NewService(Deps{ DB: db, Enabled: true, Chance: &fakeChance{consumeRemaining: 0}, Evaluator: &fakeEvaluator{passed: true}, Picker: &fakePicker{idx: 0}, Registry: &stubRegistry{handlers: map[string]lottery.PrizeHandler{ lottery.PrizeTypeManualOther: &recordingHandler{handlerType: lottery.PrizeTypeManualOther, auto: false}, }}, ContextBuilder: fakeContextBuilder{}, }) if _, err := svc.Draw(context.Background(), Request{UserId: 87437, ActivityId: 100, ClientNonce: "hif-11-regression"}); err != nil { t.Fatalf("Draw: %v", err) } if err := mock.ExpectationsWereMet(); err != nil { t.Fatalf("expectations: %v", err) } } // claimDataIsValidJSON is a per-arg matcher: the value MUST be a non-empty // string that parses as valid JSON. The exact regression (HIF-11) is // ClaimData=="" — MySQL error 3140 rejects it on JSON columns. type claimDataIsValidJSON struct{ t *testing.T } func (m claimDataIsValidJSON) Match(v driver.Value) bool { s, ok := v.(string) if !ok { m.t.Fatalf("F7 guard: expected string for ClaimData, got %T (%v)", v, v) } if s == "" { m.t.Fatalf("F7 regression (HIF-11): ClaimData must not be empty string (MySQL error 3140)") } if !json.Valid([]byte(s)) { m.t.Fatalf("F7 guard: ClaimData must be valid JSON, got %q", s) } return true }