Files
2026-10-31 12:24:00 +08:00

187 lines
6.1 KiB
Go

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