172 lines
6.9 KiB
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))
|
|
}
|
|
}
|