package handler import ( "context" "encoding/json" "errors" "strings" "testing" "time" "github.com/DATA-DOG/go-sqlmock" "github.com/perfect-panel/server/internal/model/lottery" usermodel "github.com/perfect-panel/server/internal/model/user" "gorm.io/driver/mysql" "gorm.io/gorm" ) // newHandlerTestDB 建一个 sqlmock 支撑的 gorm.DB,子测试直接把它当 tx 传给 handler。 func newHandlerTestDB(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 errors.New("actual sql does not contain expected: " + expected) }))) if err != nil { t.Fatalf("create sqlmock: %v", err) } db, err := gorm.Open(mysql.New(mysql.Config{Conn: sqlDB, SkipInitializeWithVersion: true}), &gorm.Config{ SkipDefaultTransaction: true, }) if err != nil { _ = sqlDB.Close() t.Fatalf("open gorm db: %v", err) } return db, mock, func() { _ = sqlDB.Close() } } // fakeLedger 让 handler 单测不依赖真实 SQL,只验证控制流。 type fakeLedger struct { reserveFn func(ctx context.Context, tx *gorm.DB, entry lottery.GrantLedger) (*lottery.GrantLedger, bool, error) } func (f *fakeLedger) Reserve(ctx context.Context, tx *gorm.DB, entry lottery.GrantLedger) (*lottery.GrantLedger, bool, error) { return f.reserveFn(ctx, tx, entry) } // fakeUserModel 满足 usermodel.Model 里 handler 用到的两个方法。 type fakeUserModel struct { usermodel.Model findActive func(ctx context.Context, userID int64) (*usermodel.Subscribe, error) updateSubscribe func(ctx context.Context, sub *usermodel.Subscribe, tx ...*gorm.DB) error } func (f *fakeUserModel) FindActiveSubscribe(ctx context.Context, userID int64) (*usermodel.Subscribe, error) { return f.findActive(ctx, userID) } func (f *fakeUserModel) UpdateSubscribe(ctx context.Context, sub *usermodel.Subscribe, tx ...*gorm.DB) error { return f.updateSubscribe(ctx, sub, tx...) } // identityResolver 单测里的家庭组归位:始终返回自身。 func identityResolver(_ context.Context, userID int64) (int64, error) { return userID, nil } func TestVPNDuration_RequiresIdempotencyKey(t *testing.T) { db, _, cleanup := newHandlerTestDB(t) defer cleanup() h := NewVPNDurationHandler(VPNDurationDeps{ Ledger: &fakeLedger{}, DB: db, ResolveEffectiveUser: identityResolver, }) _, err := h.Dispatch(context.Background(), db, lottery.DispatchRequest{}) if err == nil || !strings.Contains(err.Error(), "IdempotencyKey") { t.Fatalf("expected IdempotencyKey error, got %v", err) } } func TestVPNDuration_RequiresTx(t *testing.T) { h := NewVPNDurationHandler(VPNDurationDeps{Ledger: &fakeLedger{}}) _, err := h.Dispatch(context.Background(), nil, lottery.DispatchRequest{IdempotencyKey: "k"}) if err == nil || !strings.Contains(err.Error(), "transaction") { t.Fatalf("expected tx error, got %v", err) } } func TestVPNDuration_IdempotentHitReturnsStoredMessage(t *testing.T) { db, _, cleanup := newHandlerTestDB(t) defer cleanup() stored := lottery.GrantLedger{ Id: 9, ExternalRef: "lottery:100:200", Payload: `{"message":"已加 3 天到订阅"}`, } ledger := &fakeLedger{ reserveFn: func(_ context.Context, _ *gorm.DB, _ lottery.GrantLedger) (*lottery.GrantLedger, bool, error) { return &stored, true, nil }, } fake := &fakeUserModel{ findActive: func(context.Context, int64) (*usermodel.Subscribe, error) { t.Fatal("must NOT touch UserModel on idempotent hit") return nil, nil }, updateSubscribe: func(context.Context, *usermodel.Subscribe, ...*gorm.DB) error { t.Fatal("must NOT touch UserModel on idempotent hit") return nil }, } h := NewVPNDurationHandler(VPNDurationDeps{ Ledger: ledger, UserModel: fake, DB: db, ResolveEffectiveUser: identityResolver, }) res, err := h.Dispatch(context.Background(), db, lottery.DispatchRequest{ UserId: 42, ActivityId: 100, DrawId: 200, Prize: lottery.Prize{Config: `{"duration_days":3}`}, IdempotencyKey: "lottery:100:200", }) if err != nil { t.Fatalf("Dispatch: %v", err) } if res.State != lottery.DispatchStateAutoClaimed { t.Fatalf("expected auto_claimed, got %q", res.State) } if !strings.Contains(res.Message, "免费时长将会自动添加") { t.Fatalf("expected stored message, got %q", res.Message) } } func TestVPNDuration_NoActiveSubscribeSkipsWithoutError(t *testing.T) { db, mock, cleanup := newHandlerTestDB(t) defer cleanup() ledger := &fakeLedger{ reserveFn: func(_ context.Context, _ *gorm.DB, entry lottery.GrantLedger) (*lottery.GrantLedger, bool, error) { return &lottery.GrantLedger{Id: 5, ExternalRef: entry.ExternalRef}, false, nil }, } fake := &fakeUserModel{ findActive: func(context.Context, int64) (*usermodel.Subscribe, error) { return nil, gorm.ErrRecordNotFound }, updateSubscribe: func(context.Context, *usermodel.Subscribe, ...*gorm.DB) error { t.Fatal("must NOT UpdateSubscribe when no active sub") return nil }, } // fallback query returns no rows either mock.ExpectQuery("FROM `user_subscribe`"). WillReturnError(gorm.ErrRecordNotFound) mock.ExpectExec("UPDATE `lottery_grant_ledger`"). WillReturnResult(sqlmock.NewResult(0, 1)) h := NewVPNDurationHandler(VPNDurationDeps{ Ledger: ledger, UserModel: fake, DB: db, ResolveEffectiveUser: identityResolver, }) res, err := h.Dispatch(context.Background(), db, lottery.DispatchRequest{ UserId: 42, ActivityId: 100, DrawId: 200, Prize: lottery.Prize{Config: `{"duration_days":3}`}, IdempotencyKey: "lottery:100:200", }) if err != nil { t.Fatalf("Dispatch: %v", err) } if res.State != lottery.DispatchStateAutoClaimed { t.Fatalf("expected auto_claimed even on skip, got %q", res.State) } if !strings.Contains(res.Message, "免费时长将会自动添加") { t.Fatalf("expected skip message, got %q", res.Message) } } func TestVPNDuration_ExtendsExistingExpireTime(t *testing.T) { db, mock, cleanup := newHandlerTestDB(t) defer cleanup() future := time.Now().Add(10 * 24 * time.Hour).Truncate(time.Second) activeSub := &usermodel.Subscribe{ Id: 77, UserId: 42, ExpireTime: future, Status: 1, } ledger := &fakeLedger{ reserveFn: func(_ context.Context, _ *gorm.DB, _ lottery.GrantLedger) (*lottery.GrantLedger, bool, error) { return &lottery.GrantLedger{Id: 5, ExternalRef: "lottery:100:200"}, false, nil }, } updateCalled := false fake := &fakeUserModel{ findActive: func(context.Context, int64) (*usermodel.Subscribe, error) { return activeSub, nil }, updateSubscribe: func(_ context.Context, sub *usermodel.Subscribe, _ ...*gorm.DB) error { updateCalled = true expected := future.Add(3 * 24 * time.Hour) if !sub.ExpireTime.Equal(expected) { t.Fatalf("expire time not stacked: got %s want %s", sub.ExpireTime, expected) } if sub.Status != 1 { t.Fatalf("expected Status=1 after grant, got %d", sub.Status) } return nil }, } mock.ExpectExec("UPDATE `lottery_grant_ledger`"). WillReturnResult(sqlmock.NewResult(0, 1)) h := NewVPNDurationHandler(VPNDurationDeps{ Ledger: ledger, UserModel: fake, DB: db, ResolveEffectiveUser: identityResolver, }) res, err := h.Dispatch(context.Background(), db, lottery.DispatchRequest{ UserId: 42, ActivityId: 100, DrawId: 200, Prize: lottery.Prize{Config: `{"duration_days":3}`}, IdempotencyKey: "lottery:100:200", }) if err != nil { t.Fatalf("Dispatch: %v", err) } if !updateCalled { t.Fatalf("expected UpdateSubscribe to be called") } if !strings.Contains(res.Message, "免费时长将会自动添加") { t.Fatalf("unexpected message: %q", res.Message) } } func TestVPNDuration_ExpiredSubscribeRestartsFromNow(t *testing.T) { db, mock, cleanup := newHandlerTestDB(t) defer cleanup() past := time.Now().Add(-24 * time.Hour) activeSub := &usermodel.Subscribe{ Id: 77, ExpireTime: past, } ledger := &fakeLedger{ reserveFn: func(_ context.Context, _ *gorm.DB, _ lottery.GrantLedger) (*lottery.GrantLedger, bool, error) { return &lottery.GrantLedger{Id: 5}, false, nil }, } fake := &fakeUserModel{ findActive: func(context.Context, int64) (*usermodel.Subscribe, error) { return activeSub, nil }, updateSubscribe: func(_ context.Context, sub *usermodel.Subscribe, _ ...*gorm.DB) error { delta := time.Until(sub.ExpireTime) if delta < 3*24*time.Hour-5*time.Second || delta > 3*24*time.Hour+5*time.Second { t.Fatalf("expected ~3 days from now, got %v", delta) } return nil }, } mock.ExpectExec("UPDATE `lottery_grant_ledger`").WillReturnResult(sqlmock.NewResult(0, 1)) h := NewVPNDurationHandler(VPNDurationDeps{ Ledger: ledger, UserModel: fake, DB: db, ResolveEffectiveUser: identityResolver, }) _, _ = h.Dispatch(context.Background(), db, lottery.DispatchRequest{ Prize: lottery.Prize{Config: `{"duration_days":3}`}, IdempotencyKey: "k", }) } func TestVPNDuration_NoLimitNotExtended(t *testing.T) { db, mock, cleanup := newHandlerTestDB(t) defer cleanup() noLimit := time.UnixMilli(0) activeSub := &usermodel.Subscribe{ Id: 77, ExpireTime: noLimit, Status: 1, } ledger := &fakeLedger{ reserveFn: func(_ context.Context, _ *gorm.DB, _ lottery.GrantLedger) (*lottery.GrantLedger, bool, error) { return &lottery.GrantLedger{Id: 5}, false, nil }, } fake := &fakeUserModel{ findActive: func(context.Context, int64) (*usermodel.Subscribe, error) { return activeSub, nil }, updateSubscribe: func(_ context.Context, sub *usermodel.Subscribe, _ ...*gorm.DB) error { if !sub.ExpireTime.Equal(noLimit) { t.Fatalf("no-limit ExpireTime must not be extended, got %v", sub.ExpireTime) } return nil }, } mock.ExpectExec("UPDATE `lottery_grant_ledger`").WillReturnResult(sqlmock.NewResult(0, 1)) h := NewVPNDurationHandler(VPNDurationDeps{ Ledger: ledger, UserModel: fake, DB: db, ResolveEffectiveUser: identityResolver, }) _, _ = h.Dispatch(context.Background(), db, lottery.DispatchRequest{ Prize: lottery.Prize{Config: `{"duration_days":3}`}, IdempotencyKey: "k", }) } func TestVPNDuration_BadConfigRejected(t *testing.T) { db, _, cleanup := newHandlerTestDB(t) defer cleanup() h := NewVPNDurationHandler(VPNDurationDeps{ Ledger: &fakeLedger{}, DB: db, ResolveEffectiveUser: identityResolver, }) tests := []struct { name string config string }{ {name: "invalid json", config: `{bad`}, {name: "zero days", config: `{"duration_days":0}`}, {name: "negative days", config: `{"duration_days":-1}`}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { _, err := h.Dispatch(context.Background(), db, lottery.DispatchRequest{ Prize: lottery.Prize{Config: tt.config}, IdempotencyKey: "k", }) if err == nil { t.Fatalf("expected error on %s", tt.name) } }) } _ = json.Unmarshal } // TestVPNDuration_NoActiveSubscribeCreatesSubscription 覆盖“无活跃订阅 + 奖品配置了 // subscribe_id”时按该套餐新建订阅并发放时长的路径(问题2 的修复)。 func TestVPNDuration_NoActiveSubscribeCreatesSubscription(t *testing.T) { db, mock, cleanup := newHandlerTestDB(t) defer cleanup() ledger := &fakeLedger{ reserveFn: func(_ context.Context, _ *gorm.DB, entry lottery.GrantLedger) (*lottery.GrantLedger, bool, error) { return &lottery.GrantLedger{Id: 5, ExternalRef: entry.ExternalRef}, false, nil }, } fake := &fakeUserModel{ findActive: func(context.Context, int64) (*usermodel.Subscribe, error) { return nil, gorm.ErrRecordNotFound }, updateSubscribe: func(context.Context, *usermodel.Subscribe, ...*gorm.DB) error { t.Fatal("must NOT UpdateSubscribe when creating a new subscription") return nil }, } // 1) findActiveSubscribe 的 DB 回退查询 → 无行 mock.ExpectQuery("FROM `user_subscribe`"). WillReturnError(gorm.ErrRecordNotFound) // 2) 加载 subscribe 套餐计划 mock.ExpectQuery("FROM `subscribe`"). WillReturnRows(sqlmock.NewRows([]string{"id", "traffic", "node_group_id"}). AddRow(int64(7), int64(1024), int64(3))) // 3) 新建 user_subscribe mock.ExpectExec("INSERT INTO `user_subscribe`"). WillReturnResult(sqlmock.NewResult(555, 1)) // 4) 回写 ledger payload mock.ExpectExec("UPDATE `lottery_grant_ledger`"). WillReturnResult(sqlmock.NewResult(0, 1)) h := NewVPNDurationHandler(VPNDurationDeps{ Ledger: ledger, UserModel: fake, DB: db, ResolveEffectiveUser: identityResolver, }) res, err := h.Dispatch(context.Background(), db, lottery.DispatchRequest{ UserId: 42, ActivityId: 100, DrawId: 200, Prize: lottery.Prize{Config: `{"duration_days":5,"subscribe_id":7}`}, IdempotencyKey: "lottery:100:200", }) if err != nil { t.Fatalf("Dispatch: %v", err) } if res.State != lottery.DispatchStateAutoClaimed { t.Fatalf("expected auto_claimed, got %q", res.State) } if !strings.Contains(res.Message, "免费时长将会自动添加") { t.Fatalf("unexpected message: %q", res.Message) } } // TestVPNDuration_NoActiveSubscribeNoPlanStillSkips 确认未配置 subscribe_id 时, // 仍沿用旧的“跳过发放”行为(不新建订阅),保持向后兼容。 func TestVPNDuration_NoActiveSubscribeNoPlanStillSkips(t *testing.T) { db, mock, cleanup := newHandlerTestDB(t) defer cleanup() ledger := &fakeLedger{ reserveFn: func(_ context.Context, _ *gorm.DB, entry lottery.GrantLedger) (*lottery.GrantLedger, bool, error) { return &lottery.GrantLedger{Id: 5, ExternalRef: entry.ExternalRef}, false, nil }, } fake := &fakeUserModel{ findActive: func(context.Context, int64) (*usermodel.Subscribe, error) { return nil, gorm.ErrRecordNotFound }, updateSubscribe: func(context.Context, *usermodel.Subscribe, ...*gorm.DB) error { t.Fatal("must NOT touch subscription when no plan configured") return nil }, } mock.ExpectQuery("FROM `user_subscribe`"). WillReturnError(gorm.ErrRecordNotFound) mock.ExpectExec("UPDATE `lottery_grant_ledger`"). WillReturnResult(sqlmock.NewResult(0, 1)) h := NewVPNDurationHandler(VPNDurationDeps{ Ledger: ledger, UserModel: fake, DB: db, ResolveEffectiveUser: identityResolver, }) res, err := h.Dispatch(context.Background(), db, lottery.DispatchRequest{ UserId: 42, ActivityId: 100, DrawId: 200, Prize: lottery.Prize{Config: `{"duration_days":5}`}, IdempotencyKey: "lottery:100:200", }) if err != nil { t.Fatalf("Dispatch: %v", err) } if !strings.Contains(res.Message, "免费时长将会自动添加") { t.Fatalf("expected skip message, got %q", res.Message) } } // TestVPNDuration_NoActiveSubscribePlanNotFound 确认配置的 subscribe_id 不存在时, // Dispatch 返回错误(让抽奖事务回滚),而不是静默成功。 func TestVPNDuration_NoActiveSubscribePlanNotFound(t *testing.T) { db, mock, cleanup := newHandlerTestDB(t) defer cleanup() ledger := &fakeLedger{ reserveFn: func(_ context.Context, _ *gorm.DB, entry lottery.GrantLedger) (*lottery.GrantLedger, bool, error) { return &lottery.GrantLedger{Id: 5, ExternalRef: entry.ExternalRef}, false, nil }, } fake := &fakeUserModel{ findActive: func(context.Context, int64) (*usermodel.Subscribe, error) { return nil, gorm.ErrRecordNotFound }, updateSubscribe: func(context.Context, *usermodel.Subscribe, ...*gorm.DB) error { return nil }, } mock.ExpectQuery("FROM `user_subscribe`"). WillReturnError(gorm.ErrRecordNotFound) mock.ExpectQuery("FROM `subscribe`"). WillReturnError(gorm.ErrRecordNotFound) h := NewVPNDurationHandler(VPNDurationDeps{ Ledger: ledger, UserModel: fake, DB: db, ResolveEffectiveUser: identityResolver, }) _, err := h.Dispatch(context.Background(), db, lottery.DispatchRequest{ UserId: 42, ActivityId: 100, DrawId: 200, Prize: lottery.Prize{Config: `{"duration_days":5,"subscribe_id":999}`}, IdempotencyKey: "lottery:100:200", }) if err == nil { t.Fatalf("expected error when configured plan is missing") } if !strings.Contains(err.Error(), "not found") { t.Fatalf("expected 'not found' error, got %v", err) } }