package service import ( "context" "encoding/json" "math" "net/http" "net/http/httptest" "strings" "sync/atomic" "testing" "time" "affiliate_dash/internal/model" ) // writeRockDashboardJSON 输出与 0666.pro 一致的 dashboard 结构,供各用例复用。 func writeRockDashboardJSON(w http.ResponseWriter, blackStock int, blackCost string) { w.Header().Set("Content-Type", "application/json") _ = json.NewEncoder(w).Encode(map[string]interface{}{ "available": map[string]int{"black": blackStock, "blue": 7}, "effectiveTokenCosts": map[string]string{"blackTokenCost": blackCost, "blueTokenCost": "13.0"}, }) } func TestRefreshDashboard_SameCostSkipsUpdate(t *testing.T) { db := newRockTestDB(t) server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if r.URL.Path != "/api/user/dashboard" { w.WriteHeader(http.StatusNotFound) return } if r.Header.Get("Authorization") != "Bearer bearer-test" { w.WriteHeader(http.StatusUnauthorized) return } writeRockDashboardJSON(w, 326, "20.0") })) defer server.Close() svc := NewRockService(db, NewRockClient(server.URL)) setRockTestBearer(t, svc, "bearer-test") // 冻结 updated_at:价格未变化时不允许产生任何 UPDATE。 frozen := time.Date(2026, 10, 1, 8, 0, 0, 0, time.UTC) if err := db.Model(&model.RockConfig{}).Where("id = 1").UpdateColumn("updated_at", frozen).Error; err != nil { t.Fatalf("冻结 updated_at 失败: %v", err) } res, err := svc.RefreshDashboard(context.Background()) if err != nil { t.Fatalf("刷新 dashboard 失败: %v", err) } if res.AvailableBlack != 326 { t.Fatalf("上游可用库存应为 326,实际 %d", res.AvailableBlack) } if math.Abs(res.BlackTokenCost-20) > 1e-9 { t.Fatalf("采购单价应为 20,实际 %v", res.BlackTokenCost) } if res.CostUpdated { t.Fatalf("价格一致时不应标记为已更新") } cfg, _ := svc.GetConfig(context.Background()) if math.Abs(cfg.BlackTokenCost-20) > 1e-9 { t.Fatalf("价格一致时库值不应变化,实际 %v", cfg.BlackTokenCost) } if !cfg.UpdatedAt.Equal(frozen) { t.Fatalf("价格一致时不应写库,updated_at 被改为 %v", cfg.UpdatedAt) } } func TestRefreshDashboard_UpdatesChangedCost(t *testing.T) { db := newRockTestDB(t) server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if r.URL.Path != "/api/user/dashboard" { w.WriteHeader(http.StatusNotFound) return } writeRockDashboardJSON(w, 88, "21.5") })) defer server.Close() svc := NewRockService(db, NewRockClient(server.URL)) setRockTestBearer(t, svc, "bearer-test") res, err := svc.RefreshDashboard(context.Background()) if err != nil { t.Fatalf("刷新 dashboard 失败: %v", err) } if !res.CostUpdated { t.Fatalf("价格变化时应标记为已更新") } if math.Abs(res.BlackTokenCost-21.5) > 1e-9 { t.Fatalf("采购单价应为 21.5,实际 %v", res.BlackTokenCost) } cfg, _ := svc.GetConfig(context.Background()) if math.Abs(cfg.BlackTokenCost-21.5) > 1e-9 { t.Fatalf("采购单价应同步为 21.5,实际 %v", cfg.BlackTokenCost) } } func TestPurchaseTokens_DowngradesWhenUpstreamStockInsufficient(t *testing.T) { db := newRockTestDB(t) var claimCalls int32 server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { switch r.URL.Path { case "/api/user/dashboard": writeRockDashboardJSON(w, 1, "20.0") case "/api/user/tokens/claim": atomic.AddInt32(&claimCalls, 1) _ = json.NewEncoder(w).Encode(map[string]interface{}{ "tokens": []map[string]interface{}{ {"id": 41001, "token": "token-1", "color": "black", "region": "Q/V可用", "status": "using", "redeem_code": "code-1"}, }, }) 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, false) if err != nil { t.Fatalf("上游库存不足时应降级采购而非失败,实际 err=%v", err) } if calls := atomic.LoadInt32(&claimCalls); calls != 1 { t.Fatalf("降级后应调用上游 claim 1 次,实际 %d 次", calls) } if len(tokens) != 1 { t.Fatalf("期望按库存上限入库 1 个 Token,实际 %d 个", len(tokens)) } summary := svc.LastPurchaseSummary() if summary == nil { t.Fatal("缺少采购摘要") } if summary.PlannedCount != 1 || summary.LimitReason != "upstream_stock" { t.Fatalf("降级信息不符: planned=%d reason=%s", summary.PlannedCount, summary.LimitReason) } var entry model.RockPurchaseLog if err := db.Order("id DESC").First(&entry).Error; err != nil { t.Fatalf("查询采购日志失败: %v", err) } if entry.Status != "partial" || entry.MaxByStock != 1 { t.Fatalf("采购日志不符: status=%s max_by_stock=%d", entry.Status, entry.MaxByStock) } } func TestPurchaseTokens_BlocksWhenDashboardFails(t *testing.T) { db := newRockTestDB(t) var claimCalls int32 server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { switch r.URL.Path { case "/api/user/dashboard": w.WriteHeader(http.StatusInternalServerError) _, _ = w.Write([]byte(`{"error":"dashboard down"}`)) case "/api/user/tokens/claim": atomic.AddInt32(&claimCalls, 1) _ = json.NewEncoder(w).Encode(map[string]interface{}{"tokens": []interface{}{}}) default: w.WriteHeader(http.StatusNotFound) } })) defer server.Close() svc := NewRockService(db, NewRockClient(server.URL)) setRockTestBearer(t, svc, "bearer-test") _, err := svc.PurchaseTokens(context.Background(), 2, true) if err == nil || !strings.Contains(err.Error(), "获取上游库存失败,已取消本次采购") { t.Fatalf("dashboard 查询失败时应阻止采购,实际 err=%v", err) } if calls := atomic.LoadInt32(&claimCalls); calls != 0 { t.Fatalf("dashboard 失败时不应调用上游 claim,实际调用 %d 次", calls) } cfg, _ := svc.GetConfig(context.Background()) if !strings.Contains(cfg.LastError, "获取上游库存失败,已取消本次采购") { t.Fatalf("last_error 应记录 dashboard 失败,实际 %q", cfg.LastError) } }