feat(pay-v2): P8 Task3 订阅创建 + 首期激活(subscription.created)+ 端点
This commit is contained in:
@@ -0,0 +1,189 @@
|
||||
package gateway_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"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"
|
||||
)
|
||||
|
||||
// fakeSubProvider 实现 provider.SubscriptionProvider:创建订阅 Checkout 返回固定 session。
|
||||
// VerifyCallback 不经它——测试直接构造 PaidEvent 走 Settle/HandleCallback 的 default 分支
|
||||
// 时也走它(HandleCallback 仍需先过 VerifyCallback 才能拿到 ev),这里让它原样透传注入的
|
||||
// JSON 回调体(与 fake.Provider.VerifyCallback 同构,便于测试直接摆事件)。
|
||||
type fakeSubProvider struct {
|
||||
sessionRef string
|
||||
}
|
||||
|
||||
func (p *fakeSubProvider) Method() string { return "substripe" }
|
||||
|
||||
func (p *fakeSubProvider) Capabilities() provider.Capabilities {
|
||||
return provider.Capabilities{
|
||||
RenderTypes: []provider.RenderType{provider.RenderRedirect},
|
||||
SupportsRecurring: true,
|
||||
RecurringKind: provider.RecurringKindGatewayScheduled,
|
||||
SettleCurrencies: []string{"USD"},
|
||||
Regions: []string{"global"},
|
||||
}
|
||||
}
|
||||
|
||||
func (p *fakeSubProvider) Create(_ context.Context, _ provider.CreateRequest) (*provider.Session, error) {
|
||||
return nil, errors.New("fakeSubProvider: one-time Create not used")
|
||||
}
|
||||
|
||||
func (p *fakeSubProvider) CreateSubscriptionCheckout(_ context.Context, req provider.CreateRequest) (*provider.Session, error) {
|
||||
return &provider.Session{
|
||||
ProviderRef: p.sessionRef,
|
||||
RenderType: provider.RenderRedirect,
|
||||
Payload: map[string]any{"url": "https://checkout.example/" + p.sessionRef, "amount_minor": req.AmountMinor},
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (p *fakeSubProvider) CancelSubscription(_ context.Context, _ string) error { return nil }
|
||||
|
||||
// VerifyCallback 直接把测试构造的 provider.PaidEvent JSON 反序列化透传回放,省去自建协议。
|
||||
func (p *fakeSubProvider) VerifyCallback(_ context.Context, in provider.CallbackInput) (*provider.PaidEvent, error) {
|
||||
var ev provider.PaidEvent
|
||||
if err := json.Unmarshal(in.Raw, &ev); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &ev, nil
|
||||
}
|
||||
|
||||
func (p *fakeSubProvider) Query(_ context.Context, req provider.QueryRequest) (*provider.PaidEvent, error) {
|
||||
return &provider.PaidEvent{ProviderRef: req.ProviderRef, Status: provider.PaidPending}, nil
|
||||
}
|
||||
|
||||
func newSubGateway(t *testing.T) (*gateway.Gateway, *fakeSubProvider, *spyEnqueuer, *store.OrderStore, *store.SubscriptionStore) {
|
||||
t.Helper()
|
||||
db := model.OpenTestDB(t)
|
||||
orders := store.NewOrderStore(db)
|
||||
refunds := store.NewRefundStore(db)
|
||||
subs := store.NewSubscriptionStore(db)
|
||||
preg := provider.NewRegistry()
|
||||
fp := &fakeSubProvider{sessionRef: "cs_test_sess1"}
|
||||
preg.Register(fp)
|
||||
areg := accounts.New([]config.AccountConfig{
|
||||
{AccountID: "sub-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)
|
||||
return g, fp, spy, orders, subs
|
||||
}
|
||||
|
||||
type stubSubResolver struct{}
|
||||
|
||||
func (stubSubResolver) Resolve(sku, currency string) (int64, string, string, error) {
|
||||
if sku != "pro_monthly" {
|
||||
return 0, "", "", gateway.ErrProductNotFound
|
||||
}
|
||||
if currency != "USD" {
|
||||
return 0, "", "", gateway.ErrProductNotFound
|
||||
}
|
||||
return 2999, "Pro 月付", "pro_monthly", nil
|
||||
}
|
||||
|
||||
func TestCreateSubscriptionPipeline(t *testing.T) {
|
||||
g, fp, _, orders, _ := newSubGateway(t)
|
||||
res, err := g.CreateSubscription(context.Background(), gateway.CreateSubscriptionInput{
|
||||
SKU: "pro_monthly", Method: "substripe", BizSystem: "pangolin", BizRef: "u-1",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("CreateSubscription: %v", err)
|
||||
}
|
||||
if res.SubID == "" || res.OrderNo == "" {
|
||||
t.Fatalf("result = %+v", res)
|
||||
}
|
||||
if res.Session.RenderType != string(provider.RenderRedirect) {
|
||||
t.Fatalf("session = %+v", res.Session)
|
||||
}
|
||||
o, err := orders.GetOrder(res.OrderNo)
|
||||
if err != nil || o.Status != model.OrderPendingV2 || o.AmountMinor != 2999 || o.Currency != "USD" {
|
||||
t.Fatalf("order = %+v, %v", o, err)
|
||||
}
|
||||
atts, _ := orders.ListAttemptsByStatus(model.AttemptPending, 10)
|
||||
if len(atts) != 1 || atts[0].ProviderRef != fp.sessionRef {
|
||||
t.Fatalf("attempt = %+v", atts)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSubscriptionActivationOnFirstPayment(t *testing.T) {
|
||||
g, fp, spy, orders, subs := newSubGateway(t)
|
||||
ctx := context.Background()
|
||||
res, err := g.CreateSubscription(ctx, gateway.CreateSubscriptionInput{
|
||||
SKU: "pro_monthly", Method: "substripe", BizSystem: "pangolin", BizRef: "u-1",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("CreateSubscription: %v", err)
|
||||
}
|
||||
|
||||
raw, err := json.Marshal(provider.PaidEvent{
|
||||
Kind: provider.EventPayment, ProviderRef: fp.sessionRef, Status: provider.PaidSucceeded,
|
||||
PaidAmountMinor: 2999, PaidCurrency: "USD", SubscriptionRef: "sub_new",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("marshal event: %v", err)
|
||||
}
|
||||
|
||||
// 首次:order 翻 paid + 订阅诞生 active + webhook 收到 2 条(payment.succeeded + subscription.created)。
|
||||
result, err := g.HandleCallback(ctx, "substripe", provider.CallbackInput{Raw: raw})
|
||||
if err != nil || result != gateway.SettleProcessed {
|
||||
t.Fatalf("HandleCallback = %v, %v", result, err)
|
||||
}
|
||||
o, err := orders.GetOrder(res.OrderNo)
|
||||
if err != nil || o.Status != model.OrderPaidV2 {
|
||||
t.Fatalf("order after settle = %+v, %v", o, err)
|
||||
}
|
||||
sub, err := subs.GetByProviderRef("substripe", "sub_new")
|
||||
if err != nil {
|
||||
t.Fatalf("subscription not created: %v", err)
|
||||
}
|
||||
if sub.Status != model.SubActive || sub.SubID != res.SubID {
|
||||
t.Fatalf("subscription = %+v, want active/%s", sub, res.SubID)
|
||||
}
|
||||
if len(spy.calls) != 2 {
|
||||
t.Fatalf("webhook calls = %d, want 2: %+v", len(spy.calls), spy.calls)
|
||||
}
|
||||
sawPaymentSucceeded, sawSubCreated := false, false
|
||||
for _, c := range spy.calls {
|
||||
switch c["event_type"] {
|
||||
case gateway.EvtPaymentSucceeded:
|
||||
sawPaymentSucceeded = true
|
||||
case gateway.EvtSubscriptionCreated:
|
||||
sawSubCreated = true
|
||||
if c["sub_id"] != res.SubID {
|
||||
t.Fatalf("subscription.created sub_id = %v, want %s", c["sub_id"], res.SubID)
|
||||
}
|
||||
}
|
||||
}
|
||||
if !sawPaymentSucceeded || !sawSubCreated {
|
||||
t.Fatalf("missing expected events: %+v", spy.calls)
|
||||
}
|
||||
|
||||
// 重投同一 event → 幂等:订单/订阅不重复变动,webhook 不再新增。
|
||||
result2, err := g.HandleCallback(ctx, "substripe", provider.CallbackInput{Raw: raw})
|
||||
if err != nil {
|
||||
t.Fatalf("HandleCallback replay: %v", err)
|
||||
}
|
||||
if result2 != gateway.SettleDuplicate {
|
||||
t.Fatalf("replay result = %v, want duplicate", result2)
|
||||
}
|
||||
if len(spy.calls) != 2 {
|
||||
t.Fatalf("webhook calls after replay = %d, want still 2: %+v", len(spy.calls), spy.calls)
|
||||
}
|
||||
subAfter, err := subs.GetByProviderRef("substripe", "sub_new")
|
||||
if err != nil {
|
||||
t.Fatalf("subscription after replay: %v", err)
|
||||
}
|
||||
if subAfter.ID != sub.ID {
|
||||
t.Fatalf("subscription duplicated on replay: %+v vs %+v", subAfter, sub)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user