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

524 lines
18 KiB
Go

package service
import (
"context"
"errors"
"strings"
"testing"
"time"
"affiliate_dash/internal/model"
)
func TestClassifyRockDeliveryError_TokenMessageWithoutSpaces(t *testing.T) {
category, _, _ := classifyRockDeliveryError(errors.New("上游返回:该Token已兑换"))
if category != model.RockIncidentCategoryTokenRedeemed {
t.Fatalf("应识别无空格 Token 已兑换错误,实际 %s", category)
}
if !strings.Contains(category, "token") {
t.Fatalf("分类内部值应保持稳定,实际 %s", category)
}
}
func TestRockDeliveryIncident_NoEntitlementLifecycle(t *testing.T) {
db := newRockTestDB(t)
svc := NewRockService(db, NewRockClient("https://example.com"))
ctx := context.Background()
token := model.RockToken{
UpstreamTokenID: "incident-1",
Token: "jwt-incident-1",
RedeemCode: "CODE-INCIDENT-1",
Status: model.RockStatusBound,
AssignedOrderNo: "ORDER-INCIDENT-1",
}
if err := db.Create(&token).Error; err != nil {
t.Fatalf("创建 Token 失败: %v", err)
}
cause := &RockUpstreamStatusError{
StatusCode: 400,
Body: `{"error":"用户不存在可以兑换的可用权益"}`,
}
incident, err := svc.RecordRockDeliveryIncident(ctx, &token, token.AssignedOrderNo, cause)
if err != nil {
t.Fatalf("记录异常失败: %v", err)
}
if incident.Category != model.RockIncidentCategoryNoEntitlement || incident.Occurrences != 1 {
t.Fatalf("异常分类或次数错误: %+v", incident)
}
if _, err := svc.RecordRockDeliveryIncident(ctx, &token, token.AssignedOrderNo, cause); err != nil {
t.Fatalf("重复记录异常失败: %v", err)
}
var stored model.RockToken
if err := db.First(&stored, token.ID).Error; err != nil {
t.Fatalf("读取 Token 失败: %v", err)
}
if stored.RedeemLastError == "" {
t.Fatal("Token 应记录最近一次发货错误")
}
blocking, err := svc.HasBlockingRockIncident(ctx, token.AssignedOrderNo)
if err != nil || blocking == nil || blocking.Category != model.RockIncidentCategoryNoEntitlement {
t.Fatalf("应查询到阻塞重试的异常: incident=%+v err=%v", blocking, err)
}
var open model.RockDeliveryIncident
if err := db.Where("token_id = ? AND status = ?", token.ID, model.RockIncidentStatusOpen).
First(&open).Error; err != nil {
t.Fatalf("读取开放异常失败: %v", err)
}
if open.Occurrences != 2 {
t.Fatalf("重复错误应累加次数,实际 %d", open.Occurrences)
}
list, total, err := svc.ListTokens(ctx, 1, 20, "no_entitlement", "")
if err != nil {
t.Fatalf("筛选无权益失败: %v", err)
}
if total != 1 || len(list) != 1 || !list[0].HasOpenIncident {
t.Fatalf("无权益筛选结果错误: total=%d list=%+v", total, list)
}
anomalyList, anomalyTotal, err := svc.ListTokens(ctx, 1, 20, model.RockStatusAnomaly, "")
if err != nil {
t.Fatalf("筛选异常失败: %v", err)
}
if anomalyTotal != 0 || len(anomalyList) != 0 {
t.Fatalf("无权益不能重复归入异常: total=%d list=%+v", anomalyTotal, anomalyList)
}
stats, err := svc.GetStats(ctx)
if err != nil {
t.Fatalf("读取统计失败: %v", err)
}
if stats.NoEntitlementStock != 1 {
t.Fatalf("无权益统计错误: %d", stats.NoEntitlementStock)
}
if stats.AnomalyStock != 0 || stats.UnresolvedAnomalyStock != 0 {
t.Fatalf("无权益不应计入异常统计: anomaly=%d unresolved=%d",
stats.AnomalyStock, stats.UnresolvedAnomalyStock)
}
actor := uint(9)
if err := svc.ResolveRockDeliveryIncident(ctx, open.ID, "已人工核对", &actor); err != nil {
t.Fatalf("处理异常失败: %v", err)
}
blocking, err = svc.HasBlockingRockIncident(ctx, token.AssignedOrderNo)
if err != nil {
t.Fatalf("处理后查询异常失败: %v", err)
}
if blocking != nil {
t.Fatalf("处理后不应继续阻塞重试: %+v", blocking)
}
}
func TestRockDeliveryIncident_OtherErrorsUseAnomalyFilter(t *testing.T) {
db := newRockTestDB(t)
svc := NewRockService(db, NewRockClient("https://example.com"))
ctx := context.Background()
token := model.RockToken{
UpstreamTokenID: "incident-2",
Token: "jwt-incident-2",
RedeemCode: "CODE-INCIDENT-2",
Status: model.RockStatusBound,
AssignedOrderNo: "ORDER-INCIDENT-2",
}
if err := db.Create(&token).Error; err != nil {
t.Fatalf("创建 Token 失败: %v", err)
}
if _, err := svc.RecordRockDeliveryIncident(ctx, &token, token.AssignedOrderNo,
errors.New("上游兑换发货接口返回错误码 400: 同一角色无法重复兑换已兑换过的道具")); err != nil {
t.Fatalf("记录异常失败: %v", err)
}
noEntitlement, totalNoEntitlement, err := svc.ListTokens(ctx, 1, 20, "no_entitlement", "")
if err != nil {
t.Fatalf("筛选无权益失败: %v", err)
}
if totalNoEntitlement != 0 || len(noEntitlement) != 0 {
t.Fatalf("其他错误不应进入无权益: %+v", noEntitlement)
}
anomaly, totalAnomaly, err := svc.ListTokens(ctx, 1, 20, model.RockStatusAnomaly, "")
if err != nil {
t.Fatalf("筛选异常失败: %v", err)
}
if totalAnomaly != 1 || len(anomaly) != 1 || anomaly[0].IncidentCategory != model.RockIncidentCategoryDuplicateItem {
t.Fatalf("其他错误应进入异常: total=%d list=%+v", totalAnomaly, anomaly)
}
}
func TestRockIncidentRetry_RequiresRetryableCategoryAndResolvesAfterApproval(t *testing.T) {
db := newRockTestDB(t)
svc := NewRockService(db, NewRockClient("https://example.com"))
ctx := context.Background()
token := model.RockToken{
UpstreamTokenID: "incident-retry-1",
Token: "jwt-incident-retry-1",
RedeemCode: "CODE-INCIDENT-RETRY-1",
Status: model.RockStatusBound,
AssignedOrderNo: "ORDER-INCIDENT-RETRY-1",
ExpectedUID: "5550001",
ActualRoleID: "5550001",
ActualRoleName: "测试角色",
}
order := model.FulfillmentOrder{
OrderNo: token.AssignedOrderNo,
MerchantID: 1,
OrderStatus: model.OrderStatusShipFailed,
ProductSKU: model.RockSKUBlackTshirt,
}
if err := db.Create(&token).Error; err != nil {
t.Fatalf("创建 Token 失败: %v", err)
}
if err := db.Create(&order).Error; err != nil {
t.Fatalf("创建订单失败: %v", err)
}
incident, err := svc.RecordRockIncident(ctx, &token, order.OrderNo,
model.RockIncidentCategoryUpstreamTimeout, "上游超时", 504, "")
if err != nil {
t.Fatalf("记录异常失败: %v", err)
}
actor := uint(9)
plan, err := svc.ApproveRockIncidentRetry(ctx, incident.ID, "已核对上游,允许一次重试", &actor)
if err != nil {
t.Fatalf("批准人工重试失败: %v", err)
}
if plan.Token.ID != token.ID || plan.Order.OrderNo != order.OrderNo {
t.Fatalf("重试计划关联错误: %+v", plan)
}
var unblocked model.RockDeliveryIncident
if err := db.First(&unblocked, incident.ID).Error; err != nil {
t.Fatalf("读取异常失败: %v", err)
}
if unblocked.Status != model.RockIncidentStatusOpen || unblocked.BlockingRetry {
t.Fatalf("批准重试后应暂时解除阻塞: %+v", unblocked)
}
if err := svc.CompleteRockIncidentRetry(ctx, token.ID, order.OrderNo, "重试成功", &actor); err != nil {
t.Fatalf("完成人工重试失败: %v", err)
}
var resolved model.RockDeliveryIncident
if err := db.First(&resolved, incident.ID).Error; err != nil {
t.Fatalf("读取已处理异常失败: %v", err)
}
if resolved.Status != model.RockIncidentStatusResolved ||
resolved.ResolutionAction != "approve_retry" ||
resolved.ResolvedBy == nil || *resolved.ResolvedBy != actor {
t.Fatalf("异常关闭结果错误: %+v", resolved)
}
}
func TestRockIncidentRetry_RejectsNoEntitlement(t *testing.T) {
db := newRockTestDB(t)
svc := NewRockService(db, NewRockClient("https://example.com"))
ctx := context.Background()
token := model.RockToken{
UpstreamTokenID: "incident-retry-2",
Token: "jwt-incident-retry-2",
Status: model.RockStatusBound,
AssignedOrderNo: "ORDER-INCIDENT-RETRY-2",
ExpectedUID: "5550002",
}
order := model.FulfillmentOrder{
OrderNo: token.AssignedOrderNo,
MerchantID: 1,
OrderStatus: model.OrderStatusShipFailed,
ProductSKU: model.RockSKUBlackTshirt,
}
if err := db.Create(&token).Error; err != nil {
t.Fatalf("创建 Token 失败: %v", err)
}
if err := db.Create(&order).Error; err != nil {
t.Fatalf("创建订单失败: %v", err)
}
incident, err := svc.RecordRockIncident(ctx, &token, order.OrderNo,
model.RockIncidentCategoryNoEntitlement, "用户不存在可以兑换的可用权益", 400, "")
if err != nil {
t.Fatalf("记录异常失败: %v", err)
}
if _, err := svc.ApproveRockIncidentRetry(ctx, incident.ID, "尝试重试", nil); err == nil {
t.Fatal("无权益异常不允许直接重试")
}
}
func TestRockProviderCancel_UsingTokenReturnsToStock(t *testing.T) {
db := newRockTestDB(t)
svc := NewRockService(db, NewRockClient("https://example.com"))
provider := NewRockProvider(svc)
ctx := context.Background()
usingAt := time.Now().Add(-10 * time.Minute)
token := model.RockToken{
UpstreamTokenID: "cancel-using",
Token: "jwt-cancel-using",
Status: model.RockStatusUsing,
AssignedOrderNo: "ORDER-CANCEL-USING",
ExpectedUID: "5550003",
UsingAt: &usingAt,
}
if err := db.Create(&token).Error; err != nil {
t.Fatalf("创建 Token 失败: %v", err)
}
if err := provider.Cancel(ctx, &model.FulfillmentOrder{OrderNo: token.AssignedOrderNo}); err != nil {
t.Fatalf("取消未绑定订单失败: %v", err)
}
var saved model.RockToken
if err := db.First(&saved, token.ID).Error; err != nil {
t.Fatalf("读取 Token 失败: %v", err)
}
if saved.Status != model.RockStatusAvailable || saved.AssignedOrderNo != "" ||
saved.ExpectedUID != "" || saved.UsingAt != nil {
t.Fatalf("未绑定 Token 应安全回库存: %+v", saved)
}
var incidentCount int64
if err := db.Model(&model.RockDeliveryIncident{}).
Where("token_id = ? AND status = ?", token.ID, model.RockIncidentStatusOpen).
Count(&incidentCount).Error; err != nil {
t.Fatalf("统计异常失败: %v", err)
}
if incidentCount != 0 {
t.Fatalf("未绑定订单取消不应生成异常,实际 %d", incidentCount)
}
}
func TestRockProviderCancel_BoundAndRedeemedCreateOrderCancelledIncident(t *testing.T) {
cases := []struct {
name string
status string
}{
{name: "bound", status: model.RockStatusBound},
{name: "redeemed", status: model.RockStatusRedeemed},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
db := newRockTestDB(t)
svc := NewRockService(db, NewRockClient("https://example.com"))
provider := NewRockProvider(svc)
token := model.RockToken{
UpstreamTokenID: "cancel-" + tc.name,
Token: "jwt-cancel-" + tc.name,
Status: tc.status,
AssignedOrderNo: "ORDER-CANCEL-" + tc.name,
ExpectedUID: "5550004",
}
if err := db.Create(&token).Error; err != nil {
t.Fatalf("创建 Token 失败: %v", err)
}
if err := provider.Cancel(context.Background(),
&model.FulfillmentOrder{OrderNo: token.AssignedOrderNo}); err != nil {
t.Fatalf("取消订单失败: %v", err)
}
var stored model.RockToken
if err := db.First(&stored, token.ID).Error; err != nil {
t.Fatalf("读取 Token 失败: %v", err)
}
if stored.Status != tc.status || stored.AssignedOrderNo != token.AssignedOrderNo {
t.Fatalf("已绑定或已发货 Token 不应自动释放: %+v", stored)
}
var incident model.RockDeliveryIncident
if err := db.Where(
"token_id = ? AND order_no = ? AND status = ? AND category = ?",
token.ID,
token.AssignedOrderNo,
model.RockIncidentStatusOpen,
model.RockIncidentCategoryOrderCancelled,
).First(&incident).Error; err != nil {
t.Fatalf("应生成订单取消冲突异常: %v", err)
}
if !incident.BlockingRetry || incident.Message == "" {
t.Fatalf("订单取消冲突异常应阻塞重试并带说明: %+v", incident)
}
})
}
}
func TestReconcileRockOrders_DoesNotBackfillHistoricalIncidents(t *testing.T) {
db := newRockTestDB(t)
svc := NewRockService(db, NewRockClient("https://example.com"))
ctx := context.Background()
tokens := []model.RockToken{
{
UpstreamTokenID: "reconcile-old-failed",
Token: "jwt-reconcile-old-failed",
Status: model.RockStatusBound,
AssignedOrderNo: "ORDER-RECONCILE-OLD-FAILED",
},
{
UpstreamTokenID: "reconcile-old-cancel",
Token: "jwt-reconcile-old-cancel",
Status: model.RockStatusRedeemed,
AssignedOrderNo: "ORDER-RECONCILE-OLD-CANCEL",
},
}
orders := []model.FulfillmentOrder{
{
OrderNo: tokens[0].AssignedOrderNo,
ClientOrderNo: "CLIENT-RECONCILE-OLD-FAILED",
MerchantID: 1,
OrderStatus: model.OrderStatusShipFailed,
FailureReason: "用户不存在可以兑换的可用权益",
ProductSKU: model.RockSKUBlackTshirt,
},
{
OrderNo: tokens[1].AssignedOrderNo,
ClientOrderNo: "CLIENT-RECONCILE-OLD-CANCEL",
MerchantID: 1,
OrderStatus: model.OrderStatusCancelled,
ProductSKU: model.RockSKUBlackTshirt,
},
}
for i := range tokens {
if err := db.Create(&tokens[i]).Error; err != nil {
t.Fatalf("创建历史 Token 失败: %v", err)
}
if err := db.Create(&orders[i]).Error; err != nil {
t.Fatalf("创建历史订单失败: %v", err)
}
}
svc.SetRedeemConfirmHook(func(context.Context, *model.FulfillmentOrder, *model.RockToken) error {
return nil
})
if err := svc.ReconcileRockOrders(ctx); err != nil {
t.Fatalf("执行对账失败: %v", err)
}
var count int64
if err := db.Model(&model.RockDeliveryIncident{}).Count(&count).Error; err != nil {
t.Fatalf("统计异常失败: %v", err)
}
if count != 0 {
t.Fatalf("历史失败/取消记录不应自动回填异常,实际 %d", count)
}
}
func TestPendingOrderDoesNotEnterShipTimeout(t *testing.T) {
db := newRockTestDB(t)
svc := NewFulfillmentService(db, nil)
old := time.Now().Add(-time.Hour)
order := model.FulfillmentOrder{
OrderNo: "ORDER-PENDING-TIMEOUT",
MerchantID: 1,
OrderStatus: model.OrderStatusDelivering,
ResultData: `{"provider_order_stage":"redeem_pending","rock_redeem_pending":true}`,
}
if err := db.Create(&order).Error; err != nil {
t.Fatalf("创建订单失败: %v", err)
}
if err := db.Model(&model.FulfillmentOrder{}).Where("id = ?", order.ID).
UpdateColumn("updated_at", old).Error; err != nil {
t.Fatalf("调整订单时间失败: %v", err)
}
changed, err := svc.MarkProcessingTimeouts(30*time.Minute, 10)
if err != nil {
t.Fatalf("执行超时扫描失败: %v", err)
}
if changed != 0 {
t.Fatalf("pending 订单不应被超时任务标记失败,实际变更 %d", changed)
}
var stored model.FulfillmentOrder
if err := db.First(&stored, order.ID).Error; err != nil {
t.Fatalf("读取订单失败: %v", err)
}
if stored.OrderStatus != model.OrderStatusDelivering {
t.Fatalf("pending 订单应保持 delivering,实际 %s", stored.OrderStatus)
}
}
func TestBlockingIncidentPreventsDeliveryRetry(t *testing.T) {
db := newRockTestDB(t)
svc := NewRockService(db, NewRockClient("https://example.com"))
provider := NewRockProvider(svc)
ctx := context.Background()
token := model.RockToken{
UpstreamTokenID: "incident-block",
Token: "jwt-incident-block",
RedeemCode: "CODE-INCIDENT-BLOCK",
Status: model.RockStatusBound,
AssignedOrderNo: "ORDER-INCIDENT-BLOCK",
}
if err := db.Create(&token).Error; err != nil {
t.Fatalf("创建 Token 失败: %v", err)
}
if _, err := svc.RecordRockDeliveryIncident(ctx, &token, token.AssignedOrderNo,
errors.New("用户不存在可以兑换的可用权益")); err != nil {
t.Fatalf("记录异常失败: %v", err)
}
state, err := provider.PrepareDelivery(ctx, &model.FulfillmentOrder{
OrderNo: token.AssignedOrderNo,
ProductSKU: model.RockSKUBlackTshirt,
}, nil)
if err != nil {
t.Fatalf("准备发货失败: %v", err)
}
if state.CanShip {
t.Fatal("存在开放异常时不得再次调用上游发货")
}
}
func TestReconcileRockOrdersRepairsTokenAndOrderStatus(t *testing.T) {
db := newRockTestDB(t)
svc := NewRockService(db, NewRockClient("https://example.com"))
ctx := context.Background()
redeemedAt := time.Now().Add(-time.Hour)
token := model.RockToken{
UpstreamTokenID: "reconcile-1",
Token: "jwt-reconcile-1",
RedeemCode: "CODE-RECONCILE-1",
Status: model.RockStatusRedeemed,
AssignedOrderNo: "ORDER-RECONCILE-1",
RedeemedAt: &redeemedAt,
}
order := model.FulfillmentOrder{
OrderNo: token.AssignedOrderNo,
ClientOrderNo: "CLIENT-RECONCILE-1",
MerchantID: 1,
OrderStatus: model.OrderStatusPaid,
ProductSKU: model.RockSKUBlackTshirt,
}
if err := db.Create(&token).Error; err != nil {
t.Fatalf("创建 Token 失败: %v", err)
}
if err := db.Create(&order).Error; err != nil {
t.Fatalf("创建订单失败: %v", err)
}
svc.SetRedeemConfirmHook(func(ctx context.Context, order *model.FulfillmentOrder, token *model.RockToken) error {
return db.Model(order).Update("order_status", model.OrderStatusDelivered).Error
})
if err := svc.ReconcileRockOrders(ctx); err != nil {
t.Fatalf("订单对账失败: %v", err)
}
var repairedOrder model.FulfillmentOrder
if err := db.First(&repairedOrder, order.ID).Error; err != nil {
t.Fatalf("读取订单失败: %v", err)
}
if repairedOrder.OrderStatus != model.OrderStatusDelivered {
t.Fatalf("redeemed Token 应修复订单为 delivered,实际 %s", repairedOrder.OrderStatus)
}
deliveredAt := time.Now()
legacyToken := model.RockToken{
UpstreamTokenID: "reconcile-2",
Token: "jwt-reconcile-2",
RedeemCode: "CODE-RECONCILE-2",
Status: model.RockStatusBound,
AssignedOrderNo: "ORDER-RECONCILE-2",
RedeemedAt: &redeemedAt,
}
deliveredOrder := model.FulfillmentOrder{
OrderNo: legacyToken.AssignedOrderNo,
ClientOrderNo: "CLIENT-RECONCILE-2",
MerchantID: 1,
OrderStatus: model.OrderStatusDelivered,
DeliveredAt: &deliveredAt,
ProductSKU: model.RockSKUBlackTshirt,
}
if err := db.Create(&legacyToken).Error; err != nil {
t.Fatalf("创建历史 Token 失败: %v", err)
}
if err := db.Create(&deliveredOrder).Error; err != nil {
t.Fatalf("创建已交付订单失败: %v", err)
}
if err := svc.ReconcileRockOrders(ctx); err != nil {
t.Fatalf("Token 对账失败: %v", err)
}
var repairedToken model.RockToken
if err := db.First(&repairedToken, legacyToken.ID).Error; err != nil {
t.Fatalf("读取 Token 失败: %v", err)
}
if repairedToken.Status != model.RockStatusRedeemed {
t.Fatalf("已交付订单的 bound Token 应修复为 redeemed,实际 %s", repairedToken.Status)
}
}