299 lines
8.7 KiB
Go
299 lines
8.7 KiB
Go
package payments
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
"net/http"
|
|
"testing"
|
|
"time"
|
|
)
|
|
|
|
func TestOptionsExplicitEmptyPresetsMarshalAsArray(t *testing.T) {
|
|
s := NewService(nil, nil, Config{PresetAmountsFen: []int64{}, MinAmountFen: 500, MaxAmountFen: 900})
|
|
options := s.Options()
|
|
if options.PresetAmountsFen == nil || options.MinAmountFen != 500 || options.MaxAmountFen != 900 {
|
|
t.Fatalf("unexpected options: %+v", options)
|
|
}
|
|
body, err := json.Marshal(options)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
var decoded map[string]any
|
|
if err := json.Unmarshal(body, &decoded); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if presets, ok := decoded["presetAmountsFen"].([]any); !ok || len(presets) != 0 {
|
|
t.Fatalf("expected JSON empty array: %s", body)
|
|
}
|
|
}
|
|
|
|
type testRepo struct {
|
|
orders map[string]Order
|
|
byKey map[string]string
|
|
settles int
|
|
}
|
|
|
|
func (r *testRepo) CreatePaymentOrder(_ context.Context, o Order) (Order, error) {
|
|
if id := r.byKey[o.RequestKey]; id != "" {
|
|
return r.orders[id], nil
|
|
}
|
|
r.orders[o.ID] = o
|
|
r.byKey[o.RequestKey] = o.ID
|
|
return o, nil
|
|
}
|
|
func (r *testRepo) GetPaymentOrder(_ context.Context, id string) (Order, error) {
|
|
o, ok := r.orders[id]
|
|
if !ok {
|
|
return Order{}, ErrNotFound
|
|
}
|
|
return o, nil
|
|
}
|
|
func (r *testRepo) ListPaymentOrders(_ context.Context, org string, _ int) ([]Order, error) {
|
|
var out []Order
|
|
for _, o := range r.orders {
|
|
if o.OrganizationID == org {
|
|
out = append(out, o)
|
|
}
|
|
}
|
|
return out, nil
|
|
}
|
|
func (r *testRepo) PendingPaymentOrders(_ context.Context, _ int) ([]Order, error) {
|
|
var out []Order
|
|
for _, o := range r.orders {
|
|
if o.Status == StatusPending {
|
|
out = append(out, o)
|
|
}
|
|
}
|
|
return out, nil
|
|
}
|
|
func (r *testRepo) UpdatePaymentOrder(_ context.Context, id, status, url string) (Order, error) {
|
|
o := r.orders[id]
|
|
if o.Status == StatusPaid {
|
|
return o, nil
|
|
}
|
|
o.Status = status
|
|
if url != "" {
|
|
o.CodeURL = url
|
|
}
|
|
o.UpdatedAt = time.Now().UTC()
|
|
r.orders[id] = o
|
|
return o, nil
|
|
}
|
|
func (r *testRepo) SettlePaymentOrder(_ context.Context, txn Transaction) (Order, error) {
|
|
o := r.orders[txn.OrderID]
|
|
if o.AmountFen != txn.AmountFen {
|
|
return Order{}, ErrVerification
|
|
}
|
|
if o.Status != StatusPaid {
|
|
r.settles++
|
|
}
|
|
o.Status = StatusPaid
|
|
o.TransactionID = txn.TransactionID
|
|
r.orders[o.ID] = o
|
|
return o, nil
|
|
}
|
|
|
|
type testProvider struct {
|
|
txn Transaction
|
|
queryErr error
|
|
closeErr error
|
|
lastCreateID string
|
|
creates, queries, closes int
|
|
}
|
|
|
|
func (p *testProvider) Create(_ context.Context, o Order) (string, error) {
|
|
p.creates++
|
|
p.lastCreateID = o.ID
|
|
return "weixin://wxpay/bizpayurl?pr=abc", nil
|
|
}
|
|
func (p *testProvider) Query(_ context.Context, _ string) (Transaction, error) {
|
|
p.queries++
|
|
return p.txn, p.queryErr
|
|
}
|
|
func (p *testProvider) Close(_ context.Context, _ string) error { p.closes++; return p.closeErr }
|
|
func (p *testProvider) ParseNotification(_ context.Context, _ *http.Request) (Transaction, error) {
|
|
return p.txn, nil
|
|
}
|
|
|
|
func newTestService() (*Service, *testRepo, *testProvider) {
|
|
r := &testRepo{orders: map[string]Order{}, byKey: map[string]string{}}
|
|
p := &testProvider{}
|
|
s := NewService(r, p, Config{Enabled: true, MchID: "mch", AppID: "app"})
|
|
return s, r, p
|
|
}
|
|
|
|
func TestCreateRetryAndConflict(t *testing.T) {
|
|
s, _, p := newTestService()
|
|
ctx := context.Background()
|
|
a, err := s.Create(ctx, "org", "account", "request", 10000)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
b, err := s.Create(ctx, "org", "account", "request", 10000)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if a.ID != b.ID || p.creates != 1 {
|
|
t.Fatalf("idempotency failed: %q %q creates=%d", a.ID, b.ID, p.creates)
|
|
}
|
|
if _, err := s.Create(ctx, "org", "account", "request", 20000); !errors.Is(err, ErrConflict) {
|
|
t.Fatalf("want conflict: %v", err)
|
|
}
|
|
}
|
|
|
|
func TestNotifyRejectsMismatchedAmountAndSettlesOnce(t *testing.T) {
|
|
s, r, p := newTestService()
|
|
ctx := context.Background()
|
|
o, err := s.Create(ctx, "org", "account", "request", 10000)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
p.txn = Transaction{OrderID: o.ID, MchID: "mch", AppID: "app", TransactionID: "wx1", TradeState: "SUCCESS", Currency: "CNY", AmountFen: 9999}
|
|
if err := s.Notify(ctx, &http.Request{}); !errors.Is(err, ErrVerification) || r.settles != 0 {
|
|
t.Fatalf("mismatch settled: %v, %d", err, r.settles)
|
|
}
|
|
p.txn.AmountFen = 10000
|
|
if err := s.Notify(ctx, &http.Request{}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := s.Notify(ctx, &http.Request{}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if r.settles != 1 {
|
|
t.Fatalf("duplicate credit: %d", r.settles)
|
|
}
|
|
}
|
|
|
|
func TestReconcileQueriesBeforeClose(t *testing.T) {
|
|
s, r, p := newTestService()
|
|
ctx := context.Background()
|
|
o, err := s.Create(ctx, "org", "account", "request", 10000)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
o.ExpiresAt = time.Now().Add(-time.Minute)
|
|
o.UpdatedAt = time.Now().Add(-time.Minute)
|
|
r.orders[o.ID] = o
|
|
p.txn = Transaction{OrderID: o.ID, MchID: "mch", AppID: "app", TransactionID: "wx1", TradeState: "SUCCESS", Currency: "CNY", AmountFen: 10000}
|
|
if err := s.Reconcile(ctx); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if r.settles != 1 || p.closes != 0 {
|
|
t.Fatalf("paid order closed: settles=%d closes=%d", r.settles, p.closes)
|
|
}
|
|
}
|
|
|
|
func TestReconcileClosesOnlyAfterConfirmedUnpaid(t *testing.T) {
|
|
s, r, p := newTestService()
|
|
ctx := context.Background()
|
|
o, err := s.Create(ctx, "org", "account", "request", 10000)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
o.ExpiresAt = time.Now().Add(-time.Minute)
|
|
o.UpdatedAt = time.Now().Add(-time.Minute)
|
|
r.orders[o.ID] = o
|
|
p.queryErr = errors.New("gateway unavailable")
|
|
if err := s.Reconcile(ctx); err == nil {
|
|
t.Fatal("expected query error")
|
|
}
|
|
if r.orders[o.ID].Status != StatusPending || p.closes != 0 || r.settles != 0 {
|
|
t.Fatal("query outage changed payment state")
|
|
}
|
|
o = r.orders[o.ID]
|
|
o.UpdatedAt = time.Now().Add(-time.Minute)
|
|
r.orders[o.ID] = o
|
|
p.queryErr = nil
|
|
p.txn = Transaction{OrderID: o.ID, MchID: "mch", AppID: "app", TradeState: "NOTPAY", Currency: "CNY", AmountFen: 10000}
|
|
if err := s.Reconcile(ctx); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if r.orders[o.ID].Status != StatusClosed || p.closes != 1 || r.settles != 0 {
|
|
t.Fatal("unpaid order was not safely closed")
|
|
}
|
|
}
|
|
|
|
func TestRecoverMissingCodeURLFromRemoteUnpaid(t *testing.T) {
|
|
s, r, p := newTestService()
|
|
ctx := context.Background()
|
|
o, err := s.Create(ctx, "org", "account", "request", 10000)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
o.CodeURL = "" // Prepay succeeded, but local URL persistence was lost.
|
|
o.UpdatedAt = time.Now().Add(-time.Minute)
|
|
r.orders[o.ID] = o
|
|
p.txn = Transaction{OrderID: o.ID, MchID: "mch", AppID: "app", TradeState: "NOTPAY", Currency: "CNY", AmountFen: 10000}
|
|
got, err := s.Get(ctx, "org", o.ID)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if got.ID != o.ID || got.CodeURL == "" || p.lastCreateID != o.ID || p.creates != 2 {
|
|
t.Fatalf("same-order prepay recovery failed: %+v lastCreateID=%s creates=%d", got, p.lastCreateID, p.creates)
|
|
}
|
|
}
|
|
|
|
func TestFailedCloseAdvancesReconcileCursor(t *testing.T) {
|
|
s, r, p := newTestService()
|
|
ctx := context.Background()
|
|
o, err := s.Create(ctx, "org", "account", "request", 10000)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
o.ExpiresAt = time.Now().Add(-time.Minute)
|
|
o.UpdatedAt = time.Now().Add(-time.Minute)
|
|
r.orders[o.ID] = o
|
|
p.txn = Transaction{OrderID: o.ID, MchID: "mch", AppID: "app", TradeState: "NOTPAY", Currency: "CNY", AmountFen: 10000}
|
|
p.closeErr = errors.New("close unavailable")
|
|
before := o.UpdatedAt
|
|
if err := s.Reconcile(ctx); err == nil {
|
|
t.Fatal("expected close failure")
|
|
}
|
|
got := r.orders[o.ID]
|
|
if got.Status != StatusPending || !got.UpdatedAt.After(before) || p.closes != 1 {
|
|
t.Fatalf("failed close did not advance cursor: %+v closes=%d", got, p.closes)
|
|
}
|
|
if err := s.Reconcile(ctx); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if p.closes != 1 {
|
|
t.Fatal("retry ignored query throttle")
|
|
}
|
|
}
|
|
|
|
func TestGetScopesBeforeQuery(t *testing.T) {
|
|
s, r, p := newTestService()
|
|
ctx := context.Background()
|
|
o, err := s.Create(ctx, "org", "account", "request", 10000)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
o.UpdatedAt = time.Now().Add(-time.Minute)
|
|
r.orders[o.ID] = o
|
|
if _, err := s.Get(ctx, "other", o.ID); !errors.Is(err, ErrNotFound) {
|
|
t.Fatalf("scope: %v", err)
|
|
}
|
|
if p.queries != 0 {
|
|
t.Fatal("queried before authorization")
|
|
}
|
|
}
|
|
|
|
func TestQueryCannotSettleDifferentOrder(t *testing.T) {
|
|
s, r, p := newTestService()
|
|
ctx := context.Background()
|
|
o, err := s.Create(ctx, "org", "account", "request", 10000)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
o.UpdatedAt = time.Now().Add(-time.Minute)
|
|
r.orders[o.ID] = o
|
|
p.txn = Transaction{OrderID: "other-order", MchID: "mch", AppID: "app", TransactionID: "wx1", TradeState: "SUCCESS", Currency: "CNY", AmountFen: 10000}
|
|
if _, err := s.Get(ctx, "org", o.ID); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if r.settles != 0 || r.orders[o.ID].Status != StatusPending {
|
|
t.Fatal("mismatched query settled payment")
|
|
}
|
|
}
|