Files
pay/internal/gateway/chargeback_test.go

172 lines
6.9 KiB
Go

package gateway_test
import (
"context"
"encoding/json"
"testing"
"github.com/wangjia/pay/config"
"github.com/wangjia/pay/internal/accounts"
"github.com/wangjia/pay/internal/gateway"
"github.com/wangjia/pay/internal/model"
"github.com/wangjia/pay/internal/provider"
"github.com/wangjia/pay/internal/store"
)
// newChargebackGateway 装配一套独立的 gateway(渠道 "substripe",复用 subscription_test.go
// 的 fakeSubProvider——它把测试注入的 JSON 原样反序列化成 provider.PaidEvent,包括
// Kind/DisputeRef/OutTradeNo 等 P8 新增字段,fake.Provider 的精简版协议做不到这点),额外
// 暴露 *store.ChargebackStore 供断言落库情况(P8 Task6 专用,不复用 newGateway/newSubGateway
// 避免改动其多处既有调用签名)。
func newChargebackGateway(t *testing.T) (*gateway.Gateway, *store.OrderStore, *store.ChargebackStore, *spyEnqueuer) {
t.Helper()
db := model.OpenTestDB(t)
orders := store.NewOrderStore(db)
refunds := store.NewRefundStore(db)
subs := store.NewSubscriptionStore(db)
chargebacks := store.NewChargebackStore(db)
preg := provider.NewRegistry()
preg.Register(&fakeSubProvider{sessionRef: "cs_cb_1"})
areg := accounts.New([]config.AccountConfig{
{AccountID: "cb-a1", Channel: "substripe", Region: "global", Enabled: true, Weight: 1},
})
picker := accounts.NewRouter(areg, nil, nil)
spy := &spyEnqueuer{}
g := gateway.New(orders, refunds, preg, picker, stubSubResolver{}, spy, "global", subs, chargebacks)
return g, orders, chargebacks, spy
}
func seedPaidOrder(t *testing.T, orders *store.OrderStore, no string) {
t.Helper()
if err := orders.CreateOrder(&model.OrderV2{
OutTradeNo: no, BizSystem: "pangolin", BizRef: "u-1", BizCode: "pro_month",
AmountMinor: 2999, Currency: "USD", Status: model.OrderPaidV2,
}); err != nil {
t.Fatalf("seed paid order: %v", err)
}
}
// TestRecordChargebackHappyPath 覆盖 brief Step1 ①②:charge.dispute.created → 落
// Chargeback 一行 + 原 order Disputed=true + webhook spy 收 chargeback.received,且不
// 自动改订单状态机(仍是 paid,只是 Disputed 打标)。
func TestRecordChargebackHappyPath(t *testing.T) {
g, orders, chargebacks, spy := newChargebackGateway(t)
seedPaidOrder(t, orders, "PAY-1")
raw, err := json.Marshal(provider.PaidEvent{
Kind: provider.EventChargeback, DisputeRef: "dp_1", OutTradeNo: "PAY-1",
ProviderPaymentRef: "pi_1", PaidAmountMinor: 2999, PaidCurrency: "USD",
Reason: "fraudulent", Status: provider.PaidFailed,
})
if err != nil {
t.Fatalf("marshal: %v", err)
}
result, err := g.HandleCallback(context.Background(), "substripe", provider.CallbackInput{Raw: raw})
if err != nil || result != gateway.SettleProcessed {
t.Fatalf("HandleCallback = %v, %v, want SettleProcessed", result, err)
}
o, err := orders.GetOrder("PAY-1")
if err != nil {
t.Fatalf("GetOrder: %v", err)
}
if !o.Disputed {
t.Fatalf("order.Disputed = false, want true")
}
if o.Status != model.OrderPaidV2 {
t.Fatalf("order.Status = %s, want unchanged paid(打标不改状态机)", o.Status)
}
_ = chargebacks // 幂等落库由下面的重投用例断言(created=false)
if len(spy.calls) != 1 {
t.Fatalf("webhook calls = %d, want 1: %+v", len(spy.calls), spy.calls)
}
c := spy.calls[0]
if c["event_type"] != gateway.EvtChargebackReceived || c["out_trade_no"] != "PAY-1" || c["dispute_ref"] != "dp_1" {
t.Fatalf("webhook payload = %+v", c)
}
}
// TestRecordChargebackDuplicateNotDoubleRecordedOrEnqueued 拒付重投(Stripe 常见重投场景)→
// Chargeback 表不双记(ON CONFLICT dispute_ref)、webhook 不双发(outbox 唯一键)。
func TestRecordChargebackDuplicateNotDoubleRecordedOrEnqueued(t *testing.T) {
g, orders, _, spy := newChargebackGateway(t)
seedPaidOrder(t, orders, "PAY-2")
raw, err := json.Marshal(provider.PaidEvent{
Kind: provider.EventChargeback, DisputeRef: "dp_2", OutTradeNo: "PAY-2",
ProviderPaymentRef: "pi_2", PaidAmountMinor: 1999, PaidCurrency: "USD",
Reason: "duplicate", Status: provider.PaidFailed,
})
if err != nil {
t.Fatalf("marshal: %v", err)
}
if _, err := g.HandleCallback(context.Background(), "substripe", provider.CallbackInput{Raw: raw}); err != nil {
t.Fatalf("HandleCallback#1: %v", err)
}
result2, err := g.HandleCallback(context.Background(), "substripe", provider.CallbackInput{Raw: raw})
if err != nil {
t.Fatalf("HandleCallback#2: %v", err)
}
if result2 != gateway.SettleDuplicate {
t.Fatalf("replay result = %v, want duplicate", result2)
}
if len(spy.calls) != 1 {
t.Fatalf("webhook calls after replay = %d, want still 1: %+v", len(spy.calls), spy.calls)
}
}
// TestRecordChargebackEmptyOutTradeNoNotEnqueued 覆盖 brief Step1 ③:out_trade_no 空
// (订阅拒付,PI 无 metadata)→ 仍落 Chargeback 记录,但不入队业务 webhook(无法定位业务单)。
func TestRecordChargebackEmptyOutTradeNoNotEnqueued(t *testing.T) {
g, _, chargebacks, spy := newChargebackGateway(t)
raw, err := json.Marshal(provider.PaidEvent{
Kind: provider.EventChargeback, DisputeRef: "dp_sub_1", OutTradeNo: "",
ProviderPaymentRef: "pi_sub_1", PaidAmountMinor: 999, PaidCurrency: "USD",
Reason: "fraudulent", Status: provider.PaidFailed,
})
if err != nil {
t.Fatalf("marshal: %v", err)
}
result, err := g.HandleCallback(context.Background(), "substripe", provider.CallbackInput{Raw: raw})
if err != nil || result != gateway.SettleProcessed {
t.Fatalf("HandleCallback = %v, %v, want SettleProcessed(已记录未转发)", result, err)
}
if len(spy.calls) != 0 {
t.Fatalf("webhook calls = %d, want 0(无法定位业务单不转发): %+v", len(spy.calls), spy.calls)
}
// 重投同一空 out_trade_no dispute → Chargeback 仍不双记(created 幂等),同样不入队。
again, err := g.HandleCallback(context.Background(), "substripe", provider.CallbackInput{Raw: raw})
if err != nil || again != gateway.SettleDuplicate {
t.Fatalf("replay = %v, %v, want duplicate", again, err)
}
if len(spy.calls) != 0 {
t.Fatalf("webhook calls after replay = %d, want still 0", len(spy.calls))
}
_ = chargebacks
}
// TestRecordChargebackUnknownOrderNotBlocking out_trade_no 非空但查单失败(极端场景,如脏
// 数据/竞态)→ 已记录 Chargeback,定位失败不阻断、不 panic,同样不转发。
func TestRecordChargebackUnknownOrderNotBlocking(t *testing.T) {
g, _, _, spy := newChargebackGateway(t)
raw, err := json.Marshal(provider.PaidEvent{
Kind: provider.EventChargeback, DisputeRef: "dp_unknown", OutTradeNo: "NOPE",
ProviderPaymentRef: "pi_unknown", PaidAmountMinor: 500, PaidCurrency: "USD",
Reason: "fraudulent", Status: provider.PaidFailed,
})
if err != nil {
t.Fatalf("marshal: %v", err)
}
result, err := g.HandleCallback(context.Background(), "substripe", provider.CallbackInput{Raw: raw})
if err != nil || result != gateway.SettleProcessed {
t.Fatalf("HandleCallback = %v, %v, want SettleProcessed(已记录,查单失败不阻断)", result, err)
}
if len(spy.calls) != 0 {
t.Fatalf("webhook calls = %d, want 0", len(spy.calls))
}
}