Files
NianAIGC/backend/internal/payments/service_test.go
T
2026-10-12 13:57:23 +08:00

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