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

240 lines
7.9 KiB
Go

package service
import (
"context"
"encoding/json"
"math"
"net/http"
"net/http/httptest"
"strings"
"sync/atomic"
"testing"
"affiliate_dash/internal/model"
)
// newPurchaseMockServer 构造覆盖采购全链路的上游 mock,返回服务与 claim 调用计数。
func newPurchaseMockServer(t *testing.T, points string, stock int, cost string, claimStatus int) (*httptest.Server, *int32) {
t.Helper()
var claimCalls int32
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/me":
_ = json.NewEncoder(w).Encode(map[string]interface{}{
"user": map[string]interface{}{"username": "tester", "points": points},
})
case "/api/user/dashboard":
writeRockDashboardJSON(w, stock, cost)
case "/api/user/tokens/claim":
atomic.AddInt32(&claimCalls, 1)
if claimStatus != http.StatusOK {
w.WriteHeader(claimStatus)
_, _ = w.Write([]byte(`{"error":"claim down"}`))
return
}
_ = json.NewEncoder(w).Encode(map[string]interface{}{
"tokens": []map[string]interface{}{
{"id": 60001, "token": "tk-1", "color": "black", "region": "Q/V可用", "status": "using", "redeem_code": "rc-1"},
},
})
case "/api/user/packages":
_ = json.NewEncoder(w).Encode(map[string]interface{}{
"package": map[string]interface{}{"id": 8001, "share_code": "share-log", "token_count": 1},
})
case "/api/package/share-log":
_ = json.NewEncoder(w).Encode(map[string]interface{}{
"package": map[string]interface{}{"id": 8001, "share_code": "share-log", "token_count": 1},
"tokens": []map[string]interface{}{
{"id": 60001, "token": "tk-1", "color": "black", "region": "Q/V可用", "status": "using", "redeem_code": "rc-1"},
},
})
default:
w.WriteHeader(http.StatusNotFound)
}
}))
return server, &claimCalls
}
func TestPurchaseTokens_DowngradesByPoints(t *testing.T) {
db := newRockTestDB(t)
server, claimCalls := newPurchaseMockServer(t, "30.0", 100, "20.0", http.StatusOK)
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 len(tokens) != 1 {
t.Fatalf("30 点 / 20 点单价期望采购 1 个,实际 %d 个", len(tokens))
}
if calls := atomic.LoadInt32(claimCalls); calls != 1 {
t.Fatalf("应调用上游 claim 1 次,实际 %d 次", calls)
}
summary := svc.LastPurchaseSummary()
if summary == nil || summary.PlannedCount != 1 || summary.LimitReason != "points" {
t.Fatalf("降级信息不符: %+v", summary)
}
var entry model.RockPurchaseLog
if err := db.Order("id DESC").First(&entry).Error; err != nil {
t.Fatalf("查询采购日志失败: %v", err)
}
if entry.Status != "partial" {
t.Fatalf("降级成交应记为 partial,实际 %s", entry.Status)
}
if entry.MaxByPoints != 1 || entry.MaxByStock != 100 {
t.Fatalf("上限快照不符: points=%d stock=%d", entry.MaxByPoints, entry.MaxByStock)
}
if math.Abs(entry.PointsBefore-30) > 1e-9 || math.Abs(entry.UnitCost-20) > 1e-9 {
t.Fatalf("积分/单价快照不符: points=%v cost=%v", entry.PointsBefore, entry.UnitCost)
}
if entry.PointsSource != "live" {
t.Fatalf("应使用实时积分,实际来源 %s", entry.PointsSource)
}
}
func TestPurchaseTokens_SkipsWhenBudgetZero(t *testing.T) {
db := newRockTestDB(t)
server, claimCalls := newPurchaseMockServer(t, "10.0", 100, "20.0", http.StatusOK)
defer server.Close()
svc := NewRockService(db, NewRockClient(server.URL))
setRockTestBearer(t, svc, "bearer-test")
_, err := svc.PurchaseTokens(context.Background(), 2, false)
if err == nil || !strings.Contains(err.Error(), "本次未采购") {
t.Fatalf("积分不足一个时不应采购,实际 err=%v", err)
}
if calls := atomic.LoadInt32(claimCalls); calls != 0 {
t.Fatalf("未采购时不应调用 claim,实际 %d 次", calls)
}
var entry model.RockPurchaseLog
if err := db.Order("id DESC").First(&entry).Error; err != nil {
t.Fatalf("查询采购日志失败: %v", err)
}
if entry.Status != "skipped" || entry.PlannedCount != 0 || entry.LimitReason != "points" {
t.Fatalf("跳过记录不符: status=%s planned=%d reason=%s", entry.Status, entry.PlannedCount, entry.LimitReason)
}
}
func TestPurchaseTokens_DowngradesByPointsAndStock(t *testing.T) {
db := newRockTestDB(t)
server, _ := newPurchaseMockServer(t, "30.0", 1, "20.0", http.StatusOK)
defer server.Close()
svc := NewRockService(db, NewRockClient(server.URL))
setRockTestBearer(t, svc, "bearer-test")
tokens, err := svc.PurchaseTokens(context.Background(), 5, false)
if err != nil {
t.Fatalf("双重限制时应降级采购,实际 err=%v", err)
}
if len(tokens) != 1 {
t.Fatalf("积分与库存都只够 1 个,实际入库 %d 个", len(tokens))
}
summary := svc.LastPurchaseSummary()
if summary == nil || summary.LimitReason != "points_and_stock" || summary.PlannedCount != 1 {
t.Fatalf("双重限制信息不符: %+v", summary)
}
}
func TestPurchaseTokens_PersistsRawResponse(t *testing.T) {
db := newRockTestDB(t)
server, _ := newPurchaseMockServer(t, "1000.0", 100, "20.0", http.StatusOK)
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))
}
summary := svc.LastPurchaseSummary()
if summary == nil || summary.LogID == 0 {
t.Fatal("缺少采购日志 ID")
}
_, stages, err := svc.GetPurchaseLog(context.Background(), summary.LogID)
if err != nil {
t.Fatalf("查询采购阶段失败: %v", err)
}
if len(stages) < 5 {
t.Fatalf("应记录 me/dashboard/claim/package/detail 至少 5 个阶段,实际 %d 个", len(stages))
}
var claimStage *model.RockPurchaseStage
for i := range stages {
if stages[i].Stage == "claim" {
claimStage = &stages[i]
break
}
}
if claimStage == nil {
t.Fatal("缺少 claim 阶段记录")
}
if claimStage.HTTPStatus != http.StatusOK {
t.Fatalf("claim 阶段状态码应为 200,实际 %d", claimStage.HTTPStatus)
}
if !strings.Contains(claimStage.ResponseBody, "60001") {
t.Fatalf("原始响应未保留,实际 %q", claimStage.ResponseBody)
}
if !strings.Contains(claimStage.RequestBody, "\"count\"") {
t.Fatalf("请求体未保留,实际 %q", claimStage.RequestBody)
}
if strings.Contains(claimStage.RequestBody, "bearer-test") {
t.Fatal("请求体不应包含 Bearer Token")
}
}
func TestPurchaseTokens_RecordsFailedStage(t *testing.T) {
db := newRockTestDB(t)
server, _ := newPurchaseMockServer(t, "1000.0", 100, "20.0", http.StatusInternalServerError)
defer server.Close()
svc := NewRockService(db, NewRockClient(server.URL))
setRockTestBearer(t, svc, "bearer-test")
_, err := svc.PurchaseTokens(context.Background(), 1, false)
if err == nil {
t.Fatal("上游 claim 返回 500 时应当失败")
}
var entry model.RockPurchaseLog
if err := db.Order("id DESC").First(&entry).Error; err != nil {
t.Fatalf("查询采购日志失败: %v", err)
}
if entry.Status != "failed" || entry.Stage != "claim" {
t.Fatalf("失败记录不符: status=%s stage=%s", entry.Status, entry.Stage)
}
_, stages, err := svc.GetPurchaseLog(context.Background(), entry.ID)
if err != nil {
t.Fatalf("查询阶段失败: %v", err)
}
var failedStage *model.RockPurchaseStage
for i := range stages {
if stages[i].Stage == "claim" {
failedStage = &stages[i]
break
}
}
if failedStage == nil {
t.Fatal("缺少 claim 阶段记录")
}
if !strings.Contains(failedStage.ResponseBody, "claim down") {
t.Fatalf("失败响应原文未保留,实际 %q", failedStage.ResponseBody)
}
if failedStage.ErrorMessage == "" {
t.Fatal("失败阶段应记录错误信息")
}
}