Files
hi-server/internal/logic/lottery/draw/service_test.go
T
shanshanzhong147 5ef3f2717e feat(#4): 抽奖中奖用户文案调整
- 免费时长: 用户消息改为「稍后您的 N 天免费时长将会自动添加至您的账户。
  如果超过24小时未添加成功,请联系人工客服处理。」(N=中奖天数动态)
  ledger.payload.message 仍保留descriptive内部文案供后台对账
- 人工发放(crypto/manual): 消息改为「请凭此截图直接联系人工客服兑换奖励。」

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-17 02:56:38 -07:00

1082 lines
38 KiB
Go

package draw
import (
"context"
"database/sql"
"database/sql/driver"
"encoding/json"
"errors"
"fmt"
"strings"
"sync"
"testing"
"time"
"github.com/DATA-DOG/go-sqlmock"
handler "github.com/perfect-panel/server/internal/logic/lottery/handler"
"github.com/perfect-panel/server/internal/model/lottery"
usermodel "github.com/perfect-panel/server/internal/model/user"
"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
}
// TestWrapInternal_ScrubsErrorDetailsFromMsg is the HIF-4 F10 regression guard.
//
// wrapInternal 之前把 err.Error() 直接塞 xerr.CodeError.Msg,导致对外响应
// {"code":100500,"msg":"insert lottery_claim for draw 15: Error 3140 ..."}
// 把 DB 结构/表名/内部包路径全部泄露给 app 端。F10 修法:细节走日志,msg 只
// 带通用文案。此测试锁死:"wrapInternal(任意非 CodeError 的原生错误) 返回的
// CodeError.Msg 不能等于原始 err.Error()"。
func TestWrapInternal_ScrubsErrorDetailsFromMsg(t *testing.T) {
sensitive := errors.New("insert lottery_claim for draw 15: Error 3140 (22032): Invalid JSON text: The document is empty")
wrapped := wrapInternal(sensitive)
if wrapped == nil {
t.Fatal("wrapInternal returned nil for non-nil error")
}
var ce *xerr.CodeError
if !errors.As(wrapped, &ce) {
t.Fatalf("expected *xerr.CodeError, got %T", wrapped)
}
if ce.GetErrCode() != xerr.LotteryInternalError {
t.Fatalf("expected code=%d, got %d", xerr.LotteryInternalError, ce.GetErrCode())
}
if strings.Contains(ce.GetErrMsg(), "lottery_claim") ||
strings.Contains(ce.GetErrMsg(), "3140") ||
strings.Contains(ce.GetErrMsg(), "Invalid JSON") {
t.Fatalf("F10 regression: internal error detail leaked to msg: %q", ce.GetErrMsg())
}
// 反过来断言:msg 应该是标准文案(xerr.MapErrMsg 查表得到)
if ce.GetErrMsg() != xerr.MapErrMsg(xerr.LotteryInternalError) {
t.Fatalf("expected generic msg %q, got %q", xerr.MapErrMsg(xerr.LotteryInternalError), ce.GetErrMsg())
}
}
// TestWrapInternal_PreservesCodeErrors 副断言:已经是 xerr.CodeError 的错误
// 不能被 wrap 掉(它们的 msg 是设计过的对外文案,比如 4001/4002/4009)。
func TestWrapInternal_PreservesCodeErrors(t *testing.T) {
coded := xerr.NewErrCode(xerr.LotteryNoChances)
wrapped := wrapInternal(coded)
var ce *xerr.CodeError
if !errors.As(wrapped, &ce) {
t.Fatalf("expected *xerr.CodeError, got %T", wrapped)
}
if ce.GetErrCode() != xerr.LotteryNoChances {
t.Fatalf("F10 side-effect: coded error was rewrapped; got code %d instead of %d",
ce.GetErrCode(), xerr.LotteryNoChances)
}
}
// 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
}
// ---- E2E: real VPNDurationHandler wired into the draw flow -----------------
// fakeE2ELedger 让真实 VPNDurationHandler 的幂等账本 Reserve 永远返回“新建行”,
// 从而走真实发放分支(而非幂等命中)。
type fakeE2ELedger struct{}
func (fakeE2ELedger) Reserve(_ context.Context, _ *gorm.DB, entry lottery.GrantLedger) (*lottery.GrantLedger, bool, error) {
return &lottery.GrantLedger{Id: 77, ExternalRef: entry.ExternalRef}, false, nil
}
// fakeNoSubUserModel 嵌入 usermodel.Model(其余方法不会被调用),仅覆盖
// FindActiveSubscribe 返回“无活跃订阅”,模拟问题2 的复现前提。
type fakeNoSubUserModel struct {
usermodel.Model
}
func (fakeNoSubUserModel) FindActiveSubscribe(context.Context, int64) (*usermodel.Subscribe, error) {
return nil, gorm.ErrRecordNotFound
}
// TestDraw_E2E_VPNDurationAutoCreatesSubscription 串起完整链路(问题2 的端到端回归):
// 抽中 vpn_duration → 真实 handler 派发 → 用户无活跃订阅 + 奖品配了 subscribe_id →
// 同一抽奖事务内加载套餐、新建订阅、发放时长、回写账本,最终整体 COMMIT。
func TestDraw_E2E_VPNDurationAutoCreatesSubscription(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: "5 天",
Config: `{"duration_days":5,"subscribe_id":7}`,
Weight: 100,
},
)
realHandler := handler.NewVPNDurationHandler(handler.VPNDurationDeps{
UserModel: fakeNoSubUserModel{},
Ledger: fakeE2ELedger{},
DB: db,
ResolveEffectiveUser: func(_ context.Context, uid int64) (int64, error) { return uid, nil },
})
mock.ExpectBegin()
expectExistingDrawEmpty(mock)
// unlimited stock → no lottery_prize UPDATE
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))
// handler.Dispatch: findActiveSubscribe 的 DB 回退查询 → 无行
mock.ExpectQuery("FROM `user_subscribe`").
WillReturnError(gorm.ErrRecordNotFound)
// 加载 subscribe 套餐计划
mock.ExpectQuery("FROM `subscribe`").
WillReturnRows(sqlmock.NewRows([]string{"id", "traffic", "node_group_id"}).
AddRow(int64(7), int64(2048), int64(3)))
// 新建 user_subscribe
mock.ExpectExec("INSERT INTO `user_subscribe`").
WillReturnResult(sqlmock.NewResult(900, 1))
// 回写账本 payload
mock.ExpectExec("UPDATE `lottery_grant_ledger`").
WillReturnResult(sqlmock.NewResult(0, 1))
// finalize draw.dispatch_state
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{lottery.PrizeTypeVPNDuration: realHandler}},
ContextBuilder: fakeContextBuilder{},
})
res, err := svc.Draw(context.Background(), Request{UserId: 42, ActivityId: 100, ClientNonce: "e2e-1"})
if err != nil {
t.Fatalf("Draw: %v", err)
}
if !res.IsWin {
t.Fatalf("expected IsWin=true for vpn_duration")
}
if !res.Claim.AutoClaimed {
t.Fatalf("expected AutoClaimed=true, got %+v", res.Claim)
}
if !strings.Contains(res.Message, "免费时长将会自动添加") {
t.Fatalf("expected auto-create message, got %q", res.Message)
}
if res.Prize == nil || res.Prize.Type != lottery.PrizeTypeVPNDuration {
t.Fatalf("expected vpn_duration prize summary, got %+v", res.Prize)
}
if err := mock.ExpectationsWereMet(); err != nil {
t.Fatalf("unmet expectations: %v", err)
}
}
// TestDraw_E2E_VPNDurationNoPlanSkipsGrant 覆盖对照组:同样无活跃订阅,但奖品未配
// subscribe_id → 真实 handler 走“跳过发放”,不产生任何订阅相关写操作,抽奖照常提交。
func TestDraw_E2E_VPNDurationNoPlanSkipsGrant(t *testing.T) {
db, mock, cleanup := newTestDB(t)
defer cleanup()
expectRunningActivity(mock, 100)
expectPrizePool(mock, 100,
lottery.Prize{
Id: 31, ActivityId: 100, Slot: 5, Type: lottery.PrizeTypeVPNDuration,
Name: "5 天",
Config: `{"duration_days":5}`,
Weight: 100,
},
)
realHandler := handler.NewVPNDurationHandler(handler.VPNDurationDeps{
UserModel: fakeNoSubUserModel{},
Ledger: fakeE2ELedger{},
DB: db,
ResolveEffectiveUser: func(_ context.Context, uid int64) (int64, error) { return uid, nil },
})
mock.ExpectBegin()
expectExistingDrawEmpty(mock)
mock.ExpectExec("INSERT INTO `lottery_draw`").
WillReturnResult(sqlmock.NewResult(1235, 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))
// findActiveSubscribe fallback → none; then skip (no subscribe/insert)
mock.ExpectQuery("FROM `user_subscribe`").
WillReturnError(gorm.ErrRecordNotFound)
mock.ExpectExec("UPDATE `lottery_grant_ledger`").
WillReturnResult(sqlmock.NewResult(0, 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{lottery.PrizeTypeVPNDuration: realHandler}},
ContextBuilder: fakeContextBuilder{},
})
res, err := svc.Draw(context.Background(), Request{UserId: 42, ActivityId: 100, ClientNonce: "e2e-2"})
if err != nil {
t.Fatalf("Draw: %v", err)
}
if !strings.Contains(res.Message, "免费时长将会自动添加") {
t.Fatalf("expected skip message, got %q", res.Message)
}
if err := mock.ExpectationsWereMet(); err != nil {
t.Fatalf("unmet expectations: %v", err)
}
}