Files
affiliate_dash/backend/internal/service/rock_purchase_package_test.go
T
2026-11-02 18:30:51 +08:00

289 lines
10 KiB
Go

package service
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"strings"
"sync/atomic"
"testing"
"affiliate_dash/internal/model"
)
func setRockTestBearer(t *testing.T, svc *RockService, token string) {
t.Helper()
bearer := token
if _, err := svc.UpdateConfig(context.Background(), UpdateRockConfigReq{BearerToken: &bearer}); err != nil {
t.Fatalf("设置 Bearer Token 失败: %v", err)
}
// 测试默认给足积分,避免降级逻辑把采购量算成 0;积分限制由专门用例覆盖。
if err := svc.db.Model(&model.RockConfig{}).Where("id = 1").Update("points", 100000).Error; err != nil {
t.Fatalf("设置测试积分失败: %v", err)
}
}
func TestPurchaseTokens_CreatesPackageAndImportsAvailable(t *testing.T) {
db := newRockTestDB(t)
var gotClaimCount string
var gotClaimColor string
var gotIDs []string
var gotNote string
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
switch {
case r.URL.Path == "/api/user/dashboard":
writeRockDashboardJSON(w, 999, "20.0")
case r.URL.Path == "/api/user/tokens/claim":
var body struct {
Color string `json:"color"`
Count string `json:"count"`
}
_ = json.NewDecoder(r.Body).Decode(&body)
gotClaimColor = body.Color
gotClaimCount = body.Count
_ = json.NewEncoder(w).Encode(map[string]interface{}{
"tokens": []map[string]interface{}{
{"id": 30001, "token": "token-1", "color": "black", "region": "Q/V可用", "status": "using", "redeem_code": "code-1"},
{"id": 30002, "token": "token-2", "color": "black", "region": "Q/V可用", "status": "using", "redeem_code": "code-2"},
},
})
case r.URL.Path == "/api/user/packages" && r.Method == http.MethodPost:
var body struct {
IDs []string `json:"ids"`
Note string `json:"note"`
}
_ = json.NewDecoder(r.Body).Decode(&body)
gotIDs = body.IDs
gotNote = body.Note
_ = json.NewEncoder(w).Encode(map[string]interface{}{
"package": map[string]interface{}{
"id": 9001, "token_count": 2, "share_code": "share123", "status": "active", "note": body.Note,
},
})
case r.URL.Path == "/api/package/share123":
_ = json.NewEncoder(w).Encode(map[string]interface{}{
"package": map[string]interface{}{"id": 9001, "token_count": 2, "share_code": "share123", "status": "active"},
"tokens": []map[string]interface{}{
{"id": 30001, "token": "token-1", "color": "black", "region": "Q/V可用", "status": "using", "redeem_code": "code-1", "created_at": "2026-10-29 13:45:27"},
{"id": 30002, "token": "token-2", "color": "black", "region": "Q/V可用", "status": "using", "redeem_code": "code-2", "created_at": "2026-10-29 13:45:27"},
},
})
case r.URL.Path == "/api/me":
_ = json.NewEncoder(w).Encode(map[string]interface{}{
"user": map[string]interface{}{"username": "tester", "points": "880.0"},
})
default:
w.WriteHeader(http.StatusNotFound)
}
}))
defer server.Close()
svc := NewRockService(db, NewRockClient(server.URL))
setRockTestBearer(t, svc, "bearer-test")
tokens, err := svc.PurchaseTokens(context.Background(), 2, true)
if err != nil {
t.Fatalf("采购失败: %v", err)
}
if len(tokens) != 2 {
t.Fatalf("期望返回 2 个 Token,实际 %d", len(tokens))
}
if gotClaimColor != model.RockColorBlack || gotClaimCount != "2" {
t.Fatalf("claim 参数不符: color=%s count=%s", gotClaimColor, gotClaimCount)
}
if len(gotIDs) != 2 || gotIDs[0] != "30001" || gotIDs[1] != "30002" {
t.Fatalf("打包 ids 不符: %v", gotIDs)
}
if !strings.HasPrefix(gotNote, "补货-") {
t.Fatalf("自动补货 note 前缀不符: %s", gotNote)
}
var rows []model.RockToken
if err := db.Order("id ASC").Find(&rows).Error; err != nil {
t.Fatalf("查询 Token 失败: %v", err)
}
if len(rows) != 2 {
t.Fatalf("期望本地 2 条 Token,实际 %d", len(rows))
}
for _, row := range rows {
if row.Status != model.RockStatusAvailable {
t.Fatalf("新 Token 状态应为 available,实际 %s", row.Status)
}
if row.Source != "package" {
t.Fatalf("新 Token 来源应为 package,实际 %s", row.Source)
}
if row.PackageID == nil || *row.PackageID != 9001 {
t.Fatalf("package_id 未写入: %+v", row.PackageID)
}
if row.PackageShareCode != "share123" {
t.Fatalf("package_share_code 未写入,实际 %q", row.PackageShareCode)
}
if row.Token == "" || row.RedeemCode == "" {
t.Fatalf("提取码/兑换码未写入: %+v", row)
}
}
cfg, _ := svc.GetConfig(context.Background())
if cfg.LastError != "" {
t.Fatalf("成功后应清空 last_error,实际 %q", cfg.LastError)
}
if cfg.LastPurchasedAt == nil || cfg.LastAttemptAt == nil {
t.Fatalf("last_purchased_at/last_attempt_at 未记录")
}
}
func TestPurchaseTokens_PackageFailureFallsBackToPurchase(t *testing.T) {
db := newRockTestDB(t)
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
switch r.URL.Path {
case "/api/user/dashboard":
writeRockDashboardJSON(w, 999, "20.0")
case "/api/user/tokens/claim":
_ = json.NewEncoder(w).Encode(map[string]interface{}{
"tokens": []map[string]interface{}{
{"id": 31001, "token": "token-1", "color": "black", "region": "Q/V可用", "status": "using", "redeem_code": "code-1"},
},
})
case "/api/user/packages":
w.WriteHeader(http.StatusInternalServerError)
_, _ = w.Write([]byte(`{"error":"package down"}`))
default:
w.WriteHeader(http.StatusNotFound)
}
}))
defer server.Close()
svc := NewRockService(db, NewRockClient(server.URL))
setRockTestBearer(t, svc, "bearer-test")
tokens, err := svc.PurchaseTokens(context.Background(), 1, false)
if err != nil {
t.Fatalf("打包失败时应兜底成功,实际报错: %v", err)
}
if len(tokens) != 1 {
t.Fatalf("期望兜底入库 1 个,实际 %d", len(tokens))
}
var row model.RockToken
if err := db.First(&row).Error; err != nil {
t.Fatalf("查询 Token 失败: %v", err)
}
if row.Status != model.RockStatusAvailable || row.Source != "purchase" || row.PackageID != nil {
t.Fatalf("兜底入库字段不符: status=%s source=%s package_id=%v", row.Status, row.Source, row.PackageID)
}
if row.PackageShareCode != "" {
t.Fatalf("打包创建失败时不应有分享码,实际 %q", row.PackageShareCode)
}
cfg, _ := svc.GetConfig(context.Background())
if !strings.Contains(cfg.LastError, "创建上游打包记录失败") {
t.Fatalf("last_error 应记录打包失败,实际 %q", cfg.LastError)
}
}
func TestPurchaseTokens_PackageDetailFailureFallsBackWithPackageID(t *testing.T) {
db := newRockTestDB(t)
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
switch r.URL.Path {
case "/api/user/dashboard":
writeRockDashboardJSON(w, 999, "20.0")
case "/api/user/tokens/claim":
_ = json.NewEncoder(w).Encode(map[string]interface{}{
"tokens": []map[string]interface{}{
{"id": 32001, "token": "token-1", "color": "black", "region": "Q/V可用", "status": "using", "redeem_code": "code-1"},
},
})
case "/api/user/packages":
_ = json.NewEncoder(w).Encode(map[string]interface{}{
"package": map[string]interface{}{"id": 9002, "token_count": 1, "share_code": "detailfail", "status": "active"},
})
case "/api/package/detailfail":
w.WriteHeader(http.StatusInternalServerError)
_, _ = w.Write([]byte(`{"error":"detail down"}`))
default:
w.WriteHeader(http.StatusNotFound)
}
}))
defer server.Close()
svc := NewRockService(db, NewRockClient(server.URL))
setRockTestBearer(t, svc, "bearer-test")
tokens, err := svc.PurchaseTokens(context.Background(), 1, false)
if err != nil {
t.Fatalf("详情失败时应兜底成功,实际报错: %v", err)
}
if len(tokens) != 1 {
t.Fatalf("期望兜底入库 1 个,实际 %d", len(tokens))
}
var row model.RockToken
if err := db.First(&row).Error; err != nil {
t.Fatalf("查询 Token 失败: %v", err)
}
if row.Status != model.RockStatusAvailable || row.Source != "package" {
t.Fatalf("兜底入库字段不符: status=%s source=%s", row.Status, row.Source)
}
if row.PackageID == nil || *row.PackageID != 9002 {
t.Fatalf("兜底入库应保留 package_id=9002,实际 %v", row.PackageID)
}
if row.PackageShareCode != "detailfail" {
t.Fatalf("兜底入库应保留 package_share_code=detailfail,实际 %q", row.PackageShareCode)
}
cfg, _ := svc.GetConfig(context.Background())
if !strings.Contains(cfg.LastError, "详情拉取失败") {
t.Fatalf("last_error 应记录详情失败,实际 %q", cfg.LastError)
}
}
func TestAutoPurchase_FailureCooldown(t *testing.T) {
db := newRockTestDB(t)
var claimCalls int32
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
if r.URL.Path == "/api/user/dashboard" {
writeRockDashboardJSON(w, 999, "20.0")
return
}
if r.URL.Path == "/api/user/tokens/claim" {
atomic.AddInt32(&claimCalls, 1)
w.WriteHeader(http.StatusInternalServerError)
_, _ = w.Write([]byte(`{"error":"insufficient points"}`))
return
}
w.WriteHeader(http.StatusNotFound)
}))
defer server.Close()
svc := NewRockService(db, NewRockClient(server.URL))
setRockTestBearer(t, svc, "bearer-test")
if _, err := svc.PurchaseTokens(context.Background(), 50, true); err == nil {
t.Fatalf("第一次补货应因上游 500 失败")
}
if _, err := svc.PurchaseTokens(context.Background(), 50, true); err == nil || !strings.Contains(err.Error(), "冷却中") {
t.Fatalf("失败后第二次应立即命中冷却,实际 err=%v", err)
}
if calls := atomic.LoadInt32(&claimCalls); calls != 1 {
t.Fatalf("冷却期内不应再次请求上游 claim,实际调用 %d 次", calls)
}
cfg, _ := svc.GetConfig(context.Background())
if cfg.LastAttemptAt == nil {
t.Fatalf("last_attempt_at 未记录")
}
if cfg.LastAutoAttemptAt == nil {
t.Fatalf("last_auto_attempt_at 未记录")
}
if !strings.Contains(cfg.LastError, "调用上游采购接口失败") {
t.Fatalf("last_error 未记录采购失败: %q", cfg.LastError)
}
}