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) } }