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)) } }