240 lines
7.9 KiB
Go
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("失败阶段应记录错误信息")
|
|
}
|
|
}
|