07409eb602
Closes HIF-4 Stage 2 交付:人工奖领奖工单完整闭环。crypto / physical / manual_other 三类奖品从抽中到 mark-paid 的全流程可用。 - 迁移 02159_lottery_claim:UNIQUE(draw_id) + 3 支持索引,状态机 pending_claim→reviewing→paying→paid,rejected 可复活,超时 expired - 3 个 PrizeHandler:Dispatch→ErrDispatchNotSupported 兜底、ClaimSchema 各自形态、ValidateClaim 表驱动 - BuildCryptoClaimSchema:抽中时按奖品 config.networks 注入 enum,前端下拉直接可用 - Draw service dispatchOrEnqueueClaim:人工奖同 tx 插 pending_claim(回滚双清),nonce 重放回读 ExpiresAt + ClaimFormSchema - POST /claim 实装:ownership 校验 → prize 类型校验 → handler.ValidateClaim → crypto network 白名单二次校验 → tx CAS status IN (pending_claim, rejected) AND expires_at > now - Admin CRUD 5 接口:list(IN 批拉 snap + user,无 N+1)、summary(GROUP BY 一次拿计数 + overdue 单查)、approve/reject/mark-paid 全走 CAS + audit - Scheduler @every 1h 扫过期,级联 lottery_draw.dispatch_state → expired - 新增错误码 100005-100011(already_submitted / invalid_claim_data / draw_not_found / not_your_draw / claim_expired / claim_state_invalid) - Rebase 后 Stage 2 测试主动 reuse PR E 的 unmetReasonsNotEmpty + evaluatedAtNotZero matcher,人工奖分支若绕过守卫会立即挂 - Stage 1 全部 4 处 guardrail 后端 rebase 时自检过:UnmetReasons、EvaluatedAt、GrantLedger.Payload、AdminMetaMiddleware 全保留 CI 全绿;28 files, +2442/-126;覆盖率 handler 78.9% / model.lottery 74.3% / draw 68.7% / queue/lottery 76.9%
766 lines
26 KiB
Go
766 lines
26 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"
|
|
"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)
|
|
}
|
|
}
|