Files
affiliate_dash/backend/internal/service/rock_release_test.go
T

269 lines
9.7 KiB
Go

package service
import (
"context"
"encoding/json"
"errors"
"fmt"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
"affiliate_dash/internal/model"
"affiliate_dash/internal/pkg/timeutil"
)
// TestCheckReleaseToken_UpstreamStatuses 覆盖释放前上游校验的 6 种结果。
func TestCheckReleaseToken_UpstreamStatuses(t *testing.T) {
db := newRockTestDB(t)
mode := "unbound"
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
switch mode {
case "bound":
_ = json.NewEncoder(w).Encode(map[string]interface{}{
"token": map[string]interface{}{
"status": "bound",
"bound_account": "买家新角色",
"bound_account_id": "5550001",
"bind_changed_at": time.Now().In(timeutil.Location()).Add(-2 * time.Minute).Format("2006-01-02 15:04:05"),
},
})
case "redeemed":
_ = json.NewEncoder(w).Encode(map[string]interface{}{
"token": map[string]interface{}{
"status": "redeemed",
"redeemed_at": "2026-11-07 22:44:00",
},
})
case "abnormal":
_ = json.NewEncoder(w).Encode(map[string]interface{}{
"token": map[string]interface{}{"status": "abnormal"},
})
case "not_found":
w.WriteHeader(http.StatusNotFound)
_, _ = w.Write([]byte(`{"error":"token 不存在"}`))
case "check_failed":
w.WriteHeader(http.StatusInternalServerError)
_, _ = w.Write([]byte(`{"error":"boom"}`))
default:
_ = json.NewEncoder(w).Encode(map[string]interface{}{
"token": map[string]interface{}{"status": "using"},
})
}
}))
defer server.Close()
svc := NewRockService(db, NewRockClient(server.URL))
setRockTestBearer(t, svc, "bearer-test")
claimedAt := time.Now().Add(-30 * time.Minute)
cases := []struct {
name string
mode string
wantStatus string
wantCan bool
}{
{name: "unbound", mode: "unbound", wantStatus: "unbound", wantCan: true},
{name: "bound", mode: "bound", wantStatus: "bound", wantCan: false},
{name: "redeemed", mode: "redeemed", wantStatus: "redeemed", wantCan: false},
{name: "abnormal", mode: "abnormal", wantStatus: "abnormal", wantCan: false},
{name: "not_found", mode: "not_found", wantStatus: "not_found", wantCan: false},
{name: "check_failed", mode: "check_failed", wantStatus: "check_failed", wantCan: false},
}
for i, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
mode = tc.mode
token := model.RockToken{
UpstreamTokenID: fmt.Sprintf("release-check-%d", i),
Token: "jwt",
RedeemCode: fmt.Sprintf("CODE-CHECK-%d", i),
Color: model.RockColorBlack,
Status: model.RockStatusUsing,
AssignedOrderNo: fmt.Sprintf("ORDER-CHECK-%d", i),
ExpectedUID: "5550001",
ClaimedAt: &claimedAt,
}
if err := db.Create(&token).Error; err != nil {
t.Fatalf("创建 Token 失败: %v", err)
}
res, err := svc.CheckReleaseToken(context.Background(), token.ID)
if err != nil {
t.Fatalf("校验释放失败: %v", err)
}
if res.UpstreamStatus != tc.wantStatus || res.CanRelease != tc.wantCan {
t.Fatalf("校验结果不符: status=%s can_release=%v message=%s", res.UpstreamStatus, res.CanRelease, res.Message)
}
if res.Message == "" {
t.Fatalf("校验结果必须带提示文案")
}
})
}
}
// TestReleaseUnboundUsingToken_RequiresForceAndKeepsStatus 验证非强制释放被上游拦截时状态不变。
func TestReleaseUnboundUsingToken_RequiresForceAndKeepsStatus(t *testing.T) {
db := newRockTestDB(t)
server := newBoundReleaseServer(t)
defer server.Close()
svc := NewRockService(db, NewRockClient(server.URL))
setRockTestBearer(t, svc, "bearer-test")
claimedAt := time.Now().Add(-30 * time.Minute)
token := model.RockToken{
UpstreamTokenID: "release-force-1",
Token: "jwt",
RedeemCode: "CODE-FORCE-1",
Color: model.RockColorBlack,
Status: model.RockStatusUsing,
AssignedOrderNo: "ORDER-FORCE-1",
ExpectedUID: "5550001",
ClaimedAt: &claimedAt,
}
if err := db.Create(&token).Error; err != nil {
t.Fatalf("创建 Token 失败: %v", err)
}
err := svc.ReleaseUnboundUsingToken(context.Background(), token.ID, false, nil)
var forceErr *RockReleaseRequiresForceError
if !errors.As(err, &forceErr) {
t.Fatalf("非强制释放应返回 RockReleaseRequiresForceError,实际: %v", err)
}
if forceErr.Check.UpstreamStatus != "bound" || forceErr.Check.CanRelease {
t.Fatalf("校验结果不符: %+v", forceErr.Check)
}
var stored model.RockToken
if err := db.First(&stored, token.ID).Error; err != nil {
t.Fatalf("重新读取 Token 失败: %v", err)
}
if stored.Status != model.RockStatusUsing || stored.AssignedOrderNo != "ORDER-FORCE-1" {
t.Fatalf("拒绝释放后状态不应变化: status=%s order=%s", stored.Status, stored.AssignedOrderNo)
}
var logCount int64
db.Model(&model.RockTokenStatusLog{}).Where("token_id = ?", token.ID).Count(&logCount)
if logCount != 0 {
t.Fatalf("拒绝释放不应写状态日志,实际 %d 条", logCount)
}
}
// TestReleaseUnboundUsingToken_ForceClearsFieldsAndWritesLogs 验证人工强制释放清空残留字段并留下追溯记录。
func TestReleaseUnboundUsingToken_ForceClearsFieldsAndWritesLogs(t *testing.T) {
db := newRockTestDB(t)
server := newBoundReleaseServer(t)
defer server.Close()
svc := NewRockService(db, NewRockClient(server.URL))
setRockTestBearer(t, svc, "bearer-test")
now := time.Now()
claimedAt := now.Add(-30 * time.Minute)
token := model.RockToken{
UpstreamTokenID: "release-force-2",
Token: "jwt",
RedeemCode: "CODE-FORCE-2",
Color: model.RockColorBlack,
Status: model.RockStatusUsing,
AssignedOrderNo: "ORDER-FORCE-2",
ExpectedUID: "5550001",
ActualRoleName: "残留角色",
ActualRoleID: "5550001",
ActualChannel: "安卓-微信",
InitialBoundSnapshot: `{"bound":true,"role_id":"5550001"}`,
UpstreamFulfillID: 98765,
MismatchAt: &now,
MismatchReason: "历史不一致",
ClaimedAt: &claimedAt,
UsingAt: &now,
BoundAt: &now,
RedeemedAt: &now,
}
if err := db.Create(&token).Error; err != nil {
t.Fatalf("创建 Token 失败: %v", err)
}
if err := db.Create(&model.FulfillmentOrder{
OrderNo: "ORDER-FORCE-2",
ProductSKU: model.RockSKUBlackTshirt,
ResultData: fmt.Sprintf(`{"provider":"rock","rock_token_id":%d}`, token.ID),
}).Error; err != nil {
t.Fatalf("创建订单失败: %v", err)
}
actor := uint(42)
if err := svc.ReleaseUnboundUsingToken(context.Background(), token.ID, true, &actor); err != nil {
t.Fatalf("强制释放失败: %v", err)
}
var released model.RockToken
if err := db.First(&released, token.ID).Error; err != nil {
t.Fatalf("读取释放后的 Token 失败: %v", err)
}
if released.Status != model.RockStatusAvailable {
t.Fatalf("释放后应为 available,实际 %s", released.Status)
}
if released.AssignedOrderNo != "" || released.ExpectedUID != "" ||
released.ActualRoleName != "" || released.ActualRoleID != "" || released.ActualChannel != "" ||
released.InitialBoundSnapshot != "" || released.MismatchReason != "" {
t.Fatalf("释放后绑定字段未清空: %+v", released)
}
if released.MismatchAt != nil || released.UsingAt != nil || released.BoundAt != nil || released.RedeemedAt != nil {
t.Fatalf("释放后时间字段未清空: using=%v bound=%v redeemed=%v mismatch=%v",
released.UsingAt, released.BoundAt, released.RedeemedAt, released.MismatchAt)
}
if released.UpstreamFulfillID != 0 {
t.Fatalf("释放后 upstream_fulfill_id 应清空,实际 %d", released.UpstreamFulfillID)
}
var logs []model.RockTokenStatusLog
if err := db.Where("token_id = ?", token.ID).Order("id DESC").Find(&logs).Error; err != nil {
t.Fatalf("读取状态日志失败: %v", err)
}
if len(logs) != 1 {
t.Fatalf("强制释放应写 1 条状态日志,实际 %d 条", len(logs))
}
entry := logs[0]
if entry.EventType != rockTokenEventStatusChanged || entry.FromStatus != model.RockStatusUsing ||
entry.ToStatus != model.RockStatusAvailable || entry.Source != rockTokenSourceRelease || !entry.Forced {
t.Fatalf("状态日志字段不符: %+v", entry)
}
if !strings.Contains(entry.Reason, "强制释放") || !strings.Contains(entry.Reason, "bound") {
t.Fatalf("状态日志原因应包含强制标记与上游状态: %s", entry.Reason)
}
if entry.ActorUserID == nil || *entry.ActorUserID != actor {
t.Fatalf("状态日志操作人不符: %+v", entry.ActorUserID)
}
var audit model.AuditLog
if err := db.Where("action = ? AND entity_id = ?", "rock_token.release", fmt.Sprintf("%d", token.ID)).
First(&audit).Error; err != nil {
t.Fatalf("未找到释放审计日志: %v", err)
}
if audit.ActorUserID == nil || *audit.ActorUserID != actor {
t.Fatalf("审计日志操作人不符: %+v", audit.ActorUserID)
}
if !strings.Contains(audit.Metadata, `"forced":true`) || !strings.Contains(audit.Metadata, `"upstream_status":"bound"`) {
t.Fatalf("审计日志元数据不符: %s", audit.Metadata)
}
}
// newBoundReleaseServer 返回一个「上游已扫码绑定」的 mock,用于强制释放场景。
func newBoundReleaseServer(t *testing.T) *httptest.Server {
t.Helper()
return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(map[string]interface{}{
"token": map[string]interface{}{
"status": "bound",
"bound_account": "买家新角色",
"bound_account_id": "5550001",
"bind_changed_at": time.Now().In(timeutil.Location()).Add(-2 * time.Minute).Format("2006-01-02 15:04:05"),
},
})
}))
}