diff --git a/docs/membership/redeem_integration_2026-02-28.md b/docs/membership/redeem_integration_2026-02-28.md new file mode 100644 index 0000000..9b86b6e --- /dev/null +++ b/docs/membership/redeem_integration_2026-02-28.md @@ -0,0 +1,37 @@ +# 兑换码开通会员集成验证(2026-02-28) + +对应 issue:`#8 [P0][T5] 兑换码开通会员 API 集成测试` + +## 覆盖场景 + +1. 成功兑换 +- 用例:`TestRedeemCodeServiceRedeemSuccessAndRepeat` +- 结果:通过 +- 校验点:创建会员成功、`used_uses` 递增、结果字段可用。 + +2. 重复兑换 +- 用例:`TestRedeemCodeServiceRedeemSuccessAndRepeat` +- 结果:通过 +- 校验点:第二次兑换返回 `ErrRedeemCodeUsedUp`。 + +3. 过期码 +- 用例:`TestRedeemCodeServiceRedeemExpiredCode` +- 结果:通过 +- 校验点:返回 `ErrRedeemCodeExpired`。 + +4. 非法码 +- 用例:`TestRedeemCodeServiceRedeemInvalidCode` +- 结果:通过 +- 校验点:返回 `ErrRedeemCodeInvalid`。 + +5. 会员状态更新及时可见 +- 用例:`TestRedeemCodeServiceRedeemExtendsActiveMembership` +- 结果:通过 +- 校验点:已激活会员再次兑换后 `extended=true`,且 `ends_at` 向后延长。 + +## 执行命令 + +```bash +go test ./internal/membership/service -run TestRedeemCodeServiceRedeem -v +go test ./... +``` diff --git a/go.mod b/go.mod index d8960dc..9586edc 100755 --- a/go.mod +++ b/go.mod @@ -32,6 +32,7 @@ require ( github.com/klauspost/cpuid/v2 v2.3.0 // indirect github.com/leodido/go-urn v1.4.0 // indirect github.com/mattn/go-isatty v0.0.20 // indirect + github.com/mattn/go-sqlite3 v1.14.22 // indirect github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421 // indirect github.com/modern-go/reflect2 v1.0.2 // indirect github.com/pelletier/go-toml/v2 v2.2.4 // indirect @@ -49,4 +50,5 @@ require ( golang.org/x/text v0.27.0 // indirect golang.org/x/tools v0.34.0 // indirect google.golang.org/protobuf v1.36.9 // indirect + gorm.io/driver/sqlite v1.6.0 // indirect ) diff --git a/go.sum b/go.sum index 0cce27b..9f8d4b9 100644 --- a/go.sum +++ b/go.sum @@ -57,6 +57,8 @@ github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ= github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI= github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/mattn/go-sqlite3 v1.14.22 h1:2gZY6PC6kBnID23Tichd1K+Z0oS6nE/XwU+Vz/5o4kU= +github.com/mattn/go-sqlite3 v1.14.22/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y= github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421 h1:ZqeYNhU3OHLH3mGKHDcjJRFFRrJa6eAM5H+CtDdOsPc= github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M= @@ -111,5 +113,7 @@ gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gorm.io/driver/mysql v1.6.0 h1:eNbLmNTpPpTOVZi8MMxCi2aaIm0ZpInbORNXDwyLGvg= gorm.io/driver/mysql v1.6.0/go.mod h1:D/oCC2GWK3M/dqoLxnOlaNKmXz8WNTfcS9y5ovaSqKo= +gorm.io/driver/sqlite v1.6.0 h1:WHRRrIiulaPiPFmDcod6prc4l2VGVWHz80KspNsxSfQ= +gorm.io/driver/sqlite v1.6.0/go.mod h1:AO9V1qIQddBESngQUKWL9yoH93HIeA1X6V633rBwyT8= gorm.io/gorm v1.31.1 h1:7CA8FTFz/gRfgqgpeKIBcervUn3xSyPUmr6B2WXJ7kg= gorm.io/gorm v1.31.1/go.mod h1:XyQVbO2k6YkOis7C2437jSit3SsDK72s7n7rsSHd+Gs= diff --git a/internal/membership/service/redeem_code_service_integration_test.go b/internal/membership/service/redeem_code_service_integration_test.go new file mode 100644 index 0000000..bcecdc4 --- /dev/null +++ b/internal/membership/service/redeem_code_service_integration_test.go @@ -0,0 +1,154 @@ +package service + +import ( + "context" + "errors" + "testing" + "time" + + membershipmodel "wx_service/internal/membership/model" + usermodel "wx_service/internal/model" + + "gorm.io/driver/sqlite" + "gorm.io/gorm" + "gorm.io/gorm/logger" +) + +func setupRedeemTestDB(t *testing.T) *gorm.DB { + t.Helper() + + db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{ + Logger: logger.Default.LogMode(logger.Silent), + }) + if err != nil { + t.Fatalf("open sqlite: %v", err) + } + + if err := db.AutoMigrate( + &membershipmodel.MembershipRedeemCode{}, + &membershipmodel.MembershipRedemption{}, + &usermodel.UserMembership{}, + ); err != nil { + t.Fatalf("auto migrate: %v", err) + } + + return db +} + +func seedRedeemCode(t *testing.T, db *gorm.DB, plainCode string, durationDays int, expiresAt *time.Time, maxUses int, status string) membershipmodel.MembershipRedeemCode { + t.Helper() + + code := normalizeCode(plainCode) + record := membershipmodel.MembershipRedeemCode{ + CodeHash: hashCode(code), + CodeSuffix: suffixOf(code, 6), + Plan: "month", + DurationDays: durationDays, + ExpiresAt: expiresAt, + MaxUses: maxUses, + UsedUses: 0, + Status: status, + } + if err := db.Create(&record).Error; err != nil { + t.Fatalf("seed redeem code: %v", err) + } + return record +} + +func TestRedeemCodeServiceRedeemSuccessAndRepeat(t *testing.T) { + t.Parallel() + + db := setupRedeemTestDB(t) + svc := NewRedeemCodeService(db, "") + user := &usermodel.User{ID: 1, MiniProgramID: 10} + + code := "TEST-CODE-001" + seedRedeemCode(t, db, code, 30, nil, 1, "active") + + res, err := svc.Redeem(context.Background(), user, code, "127.0.0.1", "ut") + if err != nil { + t.Fatalf("redeem success: %v", err) + } + if res == nil || res.Extended { + t.Fatalf("first redeem should create membership and extended=false") + } + + var membership usermodel.UserMembership + if err := db.Where("mini_program_id = ? AND user_id = ?", user.MiniProgramID, user.ID).First(&membership).Error; err != nil { + t.Fatalf("query membership: %v", err) + } + if membership.Status != "active" { + t.Fatalf("membership status=%s, want=active", membership.Status) + } + + var storedCode membershipmodel.MembershipRedeemCode + if err := db.Where("code_hash = ?", hashCode(normalizeCode(code))).First(&storedCode).Error; err != nil { + t.Fatalf("query redeem code: %v", err) + } + if storedCode.UsedUses != 1 { + t.Fatalf("used_uses=%d, want=1", storedCode.UsedUses) + } + + _, err = svc.Redeem(context.Background(), user, code, "127.0.0.1", "ut") + if !errors.Is(err, ErrRedeemCodeUsedUp) { + t.Fatalf("repeat redeem err=%v, want ErrRedeemCodeUsedUp", err) + } +} + +func TestRedeemCodeServiceRedeemExpiredCode(t *testing.T) { + t.Parallel() + + db := setupRedeemTestDB(t) + svc := NewRedeemCodeService(db, "") + user := &usermodel.User{ID: 2, MiniProgramID: 10} + + expired := time.Now().Add(-1 * time.Hour) + code := "TEST-CODE-EXPIRED" + seedRedeemCode(t, db, code, 30, &expired, 1, "active") + + _, err := svc.Redeem(context.Background(), user, code, "127.0.0.1", "ut") + if !errors.Is(err, ErrRedeemCodeExpired) { + t.Fatalf("redeem expired err=%v, want ErrRedeemCodeExpired", err) + } +} + +func TestRedeemCodeServiceRedeemInvalidCode(t *testing.T) { + t.Parallel() + + db := setupRedeemTestDB(t) + svc := NewRedeemCodeService(db, "") + user := &usermodel.User{ID: 3, MiniProgramID: 10} + + _, err := svc.Redeem(context.Background(), user, "NOT-FOUND-CODE", "127.0.0.1", "ut") + if !errors.Is(err, ErrRedeemCodeInvalid) { + t.Fatalf("redeem invalid err=%v, want ErrRedeemCodeInvalid", err) + } +} + +func TestRedeemCodeServiceRedeemExtendsActiveMembership(t *testing.T) { + t.Parallel() + + db := setupRedeemTestDB(t) + svc := NewRedeemCodeService(db, "") + user := &usermodel.User{ID: 4, MiniProgramID: 10} + + firstCode := "TEST-CODE-A" + secondCode := "TEST-CODE-B" + seedRedeemCode(t, db, firstCode, 10, nil, 1, "active") + seedRedeemCode(t, db, secondCode, 15, nil, 1, "active") + + firstRes, err := svc.Redeem(context.Background(), user, firstCode, "127.0.0.1", "ut") + if err != nil { + t.Fatalf("first redeem: %v", err) + } + secondRes, err := svc.Redeem(context.Background(), user, secondCode, "127.0.0.1", "ut") + if err != nil { + t.Fatalf("second redeem: %v", err) + } + if secondRes == nil || !secondRes.Extended { + t.Fatalf("second redeem should extend existing membership") + } + if !secondRes.EndsAt.After(firstRes.EndsAt) { + t.Fatalf("second ends_at=%s should be after first ends_at=%s", secondRes.EndsAt, firstRes.EndsAt) + } +}