package apple import ( "bytes" "context" "encoding/base64" "encoding/json" "net/http" "net/http/httptest" "testing" "time" "github.com/alicebob/miniredis/v2" "github.com/gin-gonic/gin" "github.com/perfect-panel/server/internal/config" iapmodel "github.com/perfect-panel/server/internal/model/iap/apple" submodel "github.com/perfect-panel/server/internal/model/subscribe" usermodel "github.com/perfect-panel/server/internal/model/user" "github.com/perfect-panel/server/internal/svc" "github.com/perfect-panel/server/pkg/constant" "github.com/redis/go-redis/v9" "gorm.io/driver/sqlite" "gorm.io/gorm" ) // TestIAPAttachFlow 覆盖完整一次用户购买绑定的接口流程 // 步骤:初始化内存DB+Redis → 配置产品映射 → 创建用户与订阅计划 → 调用attach接口 → 断言返回与落库 func TestIAPAttachFlow(t *testing.T) { gin.SetMode(gin.TestMode) // sqlite 内存数据库 db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{}) if err != nil { t.Fatalf("open sqlite error: %v", err) } if err := db.AutoMigrate( &usermodel.User{}, &iapmodel.Transaction{}, ); err != nil { t.Fatalf("automigrate error: %v", err) } // sqlite 手工创建 subscribe 与 user_subscribe 表,避免不兼容的默认值语法 if err := db.Exec(` CREATE TABLE IF NOT EXISTS subscribe ( id INTEGER PRIMARY KEY, name TEXT, language TEXT, description TEXT, unit_price INTEGER, unit_time TEXT, discount TEXT, replacement INTEGER, inventory INTEGER, traffic INTEGER, speed_limit INTEGER, device_limit INTEGER, quota INTEGER, nodes TEXT, node_tags TEXT, show INTEGER, sell INTEGER, sort INTEGER, deduction_ratio INTEGER, allow_deduction INTEGER, reset_cycle INTEGER, renewal_reset INTEGER, created_at DATETIME, updated_at DATETIME ); `).Error; err != nil { t.Fatalf("create subscribe table error: %v", err) } if err := db.Exec(` CREATE TABLE IF NOT EXISTS user_subscribe ( id INTEGER PRIMARY KEY AUTOINCREMENT, user_id INTEGER NOT NULL, order_id INTEGER, subscribe_id INTEGER NOT NULL, start_time DATETIME, expire_time DATETIME, finished_at DATETIME, traffic INTEGER DEFAULT 0, download INTEGER DEFAULT 0, upload INTEGER DEFAULT 0, token TEXT UNIQUE, uuid TEXT UNIQUE, status INTEGER DEFAULT 0, created_at DATETIME, updated_at DATETIME ); `).Error; err != nil { t.Fatalf("create user_subscribe table error: %v", err) } // 内嵌 Redis mr, err := miniredis.Run() if err != nil { t.Fatalf("start miniredis error: %v", err) } defer mr.Close() rds := redis.NewClient(&redis.Options{Addr: mr.Addr()}) // 配置 IAP 产品映射 cd := `{ "iapProductMap": { "com.airport.vpn.pass.30d": { "description": "30天通行证", "priceText": "¥28.00", "durationDays": 30, "tier": "Basic", "subscribeId": 1001 } }, "iapBundleId": "co.airoport.app.ios" }` s := &svc.ServiceContext{ DB: db, Redis: rds, Config: config.Config{ Site: config.SiteConfig{ CustomData: cd, }, }, } // 初始化模型(与生产保持一致) s.UserModel = usermodel.NewModel(db, rds) s.SubscribeModel = submodel.NewModel(db, rds) s.IAPAppleTransactionModel = iapmodel.NewModel(db, rds) // 创建可售订阅计划(ID=1001) truePtr := func(b bool) *bool { return &b } if err := db.Create(&submodel.Subscribe{ Id: 1001, Name: "30D Pass", Sell: truePtr(true), Language: "", }).Error; err != nil { t.Fatalf("create subscribe plan error: %v", err) } // 创建用户 u := &usermodel.User{ Id: 1, Password: "", Avatar: "", Balance: 0, Commission: 0, ReferralPercentage: 0, OnlyFirstPurchase: truePtr(true), Enable: truePtr(true), IsAdmin: truePtr(false), EnableBalanceNotify: truePtr(false), EnableLoginNotify: truePtr(false), EnableSubscribeNotify: truePtr(true), EnableTradeNotify: truePtr(false), } if err := db.Create(u).Error; err != nil { t.Fatalf("create user error: %v", err) } // 构造最小 JWS(仅解析 payload) payload := map[string]interface{}{ "bundleId": "co.airoport.app.ios", "productId": "com.airport.vpn.pass.unknown", "transactionId": "1000000000001", "originalTransactionId": "1000000000000", "purchaseDate": float64(time.Now().UnixMilli()), } data, _ := json.Marshal(payload) b64 := base64.RawURLEncoding.EncodeToString(data) jws := "header." + b64 + ".signature" // 组装路由(仅挂载 attach) r := gin.New() r.POST("/v1/public/iap/apple/transactions/attach", AttachAppleTransactionHandler(s)) // 请求上下文注入登录用户 type attachReq struct { SignedTransactionJWS string `json:"signed_transaction_jws"` DurationDays int64 `json:"duration_days"` Tier string `json:"tier"` SubscribeId int64 `json:"subscribe_id"` } body := attachReq{SignedTransactionJWS: jws, DurationDays: 30, Tier: "Basic", SubscribeId: 1001} bodyBytes, _ := json.Marshal(body) req, _ := http.NewRequest(http.MethodPost, "/v1/public/iap/apple/transactions/attach", bytes.NewReader(bodyBytes)) req.Header.Set("Content-Type", "application/json") ctx := context.WithValue(req.Context(), constant.CtxKeyUser, u) req = req.WithContext(ctx) w := httptest.NewRecorder() r.ServeHTTP(w, req) if w.Code != http.StatusOK { t.Fatalf("attach status != 200, got %d", w.Code) } // 解析响应包装 var wrap struct { Code uint32 `json:"code"` Msg string `json:"msg"` Data struct { ExpiresAt int64 `json:"expires_at"` Tier string `json:"tier"` } `json:"data"` } if err := json.Unmarshal(w.Body.Bytes(), &wrap); err != nil { t.Fatalf("unmarshal attach resp error: %v", err) } if wrap.Code != 200 { t.Fatalf("attach code != 200, got %d, msg=%s", wrap.Code, wrap.Msg) } if wrap.Data.ExpiresAt <= time.Now().Unix() { t.Fatalf("expires_at invalid: %d", wrap.Data.ExpiresAt) } // 校验 user_subscribe 落库 var count int64 if err := db.Model(&usermodel.Subscribe{}).Where("user_id = ? AND subscribe_id = ?", u.Id, 1001).Count(&count).Error; err != nil { t.Fatalf("query user_subscribe error: %v", err) } if count == 0 { t.Fatalf("user_subscribe not inserted") } }