117 lines
4.6 KiB
Go
117 lines
4.6 KiB
Go
package postgres
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"os"
|
|
"sync"
|
|
"testing"
|
|
"time"
|
|
|
|
"git.nianxx.cn/wangxuming/NianAIGC/backend/internal/payments"
|
|
"github.com/jackc/pgx/v5/pgxpool"
|
|
)
|
|
|
|
// Run only against a disposable PostgreSQL instance. The test never reads the
|
|
// application's DATABASE_URL or local credentials.
|
|
func TestPaymentSettlementInDisposablePostgres(t *testing.T) {
|
|
databaseURL := os.Getenv("NIAN_TEST_PAYMENT_DATABASE_URL")
|
|
if databaseURL == "" {
|
|
t.Skip("set NIAN_TEST_PAYMENT_DATABASE_URL to a disposable PostgreSQL database")
|
|
}
|
|
ctx := context.Background()
|
|
pool, err := pgxpool.New(ctx, databaseURL)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
defer pool.Close()
|
|
db := NewDatabase(Config{Backend: BackendPostgres}, &pgxPoolAdapter{pool: pool})
|
|
if err := db.Readiness(ctx); err != nil {
|
|
t.Fatalf("migrations/readiness: %v", err)
|
|
}
|
|
_, err = pool.Exec(ctx, `INSERT INTO public.platform_organizations(id, name, archive_owner_id) VALUES ('pay_test_org', 'payment test org', 'pay_test_archive');
|
|
INSERT INTO public.platform_users(id, phone, display_name, role, organization_id, status, password_hash, password_salt)
|
|
VALUES ('pay_test_admin', '13900000123', 'Payment Test', 'organization_admin', 'pay_test_org', 'active', 'hash', 'salt')`)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
makeOrder := func(id, requestKey string, amount int64) payments.Order {
|
|
return payments.Order{ID: id, OrganizationID: "pay_test_org", AccountID: "pay_test_admin",
|
|
RequestKey: requestKey, MchID: "test_mch", AppID: "test_app", AmountFen: amount,
|
|
Currency: "CNY", ExpiresAt: time.Now().Add(15 * time.Minute)}
|
|
}
|
|
first, err := db.CreatePaymentOrder(ctx, makeOrder("pay_test_order_1", "pay_test_request_1", 5200))
|
|
if err != nil || first.Status != payments.StatusPending {
|
|
t.Fatalf("create first: %+v %v", first, err)
|
|
}
|
|
replayed, err := db.CreatePaymentOrder(ctx, makeOrder("pay_test_order_new", "pay_test_request_1", 5200))
|
|
if err != nil || replayed.ID != first.ID {
|
|
t.Fatalf("idempotent create: %+v %v", replayed, err)
|
|
}
|
|
if _, err := db.CreatePaymentOrder(ctx, makeOrder("pay_test_order_new", "pay_test_request_1", 5201)); !errors.Is(err, payments.ErrConflict) {
|
|
t.Fatalf("changed request payload: %v", err)
|
|
}
|
|
txn := payments.Transaction{OrderID: first.ID, MchID: "test_mch", AppID: "test_app",
|
|
TransactionID: "wx_test_transaction_1", TradeState: "SUCCESS", Currency: "CNY",
|
|
AmountFen: 5200, SuccessTime: time.Now().UTC()}
|
|
var wg sync.WaitGroup
|
|
errorsOut := make(chan error, 12)
|
|
for i := 0; i < 12; i++ {
|
|
wg.Add(1)
|
|
go func() {
|
|
defer wg.Done()
|
|
order, err := db.SettlePaymentOrder(ctx, txn)
|
|
if err == nil && order.Status != payments.StatusPaid {
|
|
err = payments.ErrConflict
|
|
}
|
|
errorsOut <- err
|
|
}()
|
|
}
|
|
wg.Wait()
|
|
close(errorsOut)
|
|
for err := range errorsOut {
|
|
if err != nil {
|
|
t.Fatalf("concurrent settlement: %v", err)
|
|
}
|
|
}
|
|
var balance, recharged int64
|
|
if err := pool.QueryRow(ctx, `SELECT balance_fen, total_recharged_fen FROM public.billing_wallets WHERE organization_id = 'pay_test_org'`).Scan(&balance, &recharged); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
var ledgerCount int
|
|
if err := pool.QueryRow(ctx, `SELECT count(*) FROM public.billing_ledger WHERE organization_id = 'pay_test_org' AND idempotency_key = 'wechat-native:test_mch:pay_test_order_1'`).Scan(&ledgerCount); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if balance != 5200 || recharged != 5200 || ledgerCount != 1 {
|
|
t.Fatalf("after 12 deliveries: balance=%d recharged=%d ledger=%d", balance, recharged, ledgerCount)
|
|
}
|
|
second, err := db.CreatePaymentOrder(ctx, makeOrder("pay_test_order_2", "pay_test_request_2", 300))
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if _, err := db.UpdatePaymentOrder(ctx, second.ID, payments.StatusClosed, ""); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
collision := txn
|
|
collision.OrderID = second.ID
|
|
collision.AmountFen = 300
|
|
if _, err := db.SettlePaymentOrder(ctx, collision); !errors.Is(err, payments.ErrConflict) {
|
|
t.Fatalf("reused WeChat transaction: %v", err)
|
|
}
|
|
stillClosed, err := db.GetPaymentOrder(ctx, second.ID)
|
|
if err != nil || stillClosed.Status != payments.StatusClosed {
|
|
t.Fatalf("transaction collision changed order: %+v %v", stillClosed, err)
|
|
}
|
|
late := collision
|
|
late.TransactionID = "wx_test_transaction_2"
|
|
if _, err := db.SettlePaymentOrder(ctx, late); err != nil {
|
|
t.Fatalf("valid late success: %v", err)
|
|
}
|
|
if err := pool.QueryRow(ctx, `SELECT balance_fen, total_recharged_fen FROM public.billing_wallets WHERE organization_id = 'pay_test_org'`).Scan(&balance, &recharged); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if balance != 5500 || recharged != 5500 {
|
|
t.Fatalf("after late success: balance=%d recharged=%d", balance, recharged)
|
|
}
|
|
}
|