Files
ttstd-infinite-canvas/backend/internal/repository/payment_test.go

424 lines
18 KiB
Go

package repository
import (
"errors"
"testing"
"time"
"infinite-canvas/backend/internal/model"
"gorm.io/driver/sqlite"
"gorm.io/gorm"
)
func TestCompletePaymentOrderGrantsCreditsExactlyOnce(t *testing.T) {
db := openPaymentTestDB(t)
repo := New(db)
order := model.PaymentOrder{
ID: "payment-order-1", UserID: "user-1", IdempotencyKey: "idem-1", MerchantOrderNo: "merchant-order-1",
ProductID: "product-1", ProductName: "100 积分", ProviderID: "wechat-native", PluginID: "plugin-1",
ProviderConfigID: "config-1", ProviderConfigVersion: 1, AmountFen: 100, Currency: "CNY",
CreditsMicrocredits: 100_000_000, Status: model.PaymentOrderPending, ExpiresAt: time.Now().Add(time.Hour),
}
if err := db.Create(&order).Error; err != nil {
t.Fatal(err)
}
evidence := PaymentEvidence{ProviderTradeNo: "wechat-trade-1", ProviderStatus: "SUCCESS", AmountFen: 100, Currency: "CNY", PaidAt: time.Now()}
completed, granted, err := repo.CompletePaymentOrder("wechat-native", "merchant-order-1", evidence)
if err != nil {
t.Fatal(err)
}
if !granted || completed.Status != model.PaymentOrderCredited {
t.Fatalf("first completion = %#v, granted=%v", completed, granted)
}
completed, granted, err = repo.CompletePaymentOrder("wechat-native", "merchant-order-1", evidence)
if err != nil {
t.Fatal(err)
}
if granted || completed.Status != model.PaymentOrderCredited {
t.Fatalf("duplicate completion = %#v, granted=%v", completed, granted)
}
var account model.CreditAccount
if err := db.First(&account, "user_id = ?", "user-1").Error; err != nil {
t.Fatal(err)
}
if account.AvailableMicrocredits != 100_000_000 {
t.Fatalf("available credits = %d", account.AvailableMicrocredits)
}
var ledgerCount int64
if err := db.Model(&model.CreditLedgerEntry{}).Where("payment_order_id = ? AND type = ?", order.ID, model.CreditLedgerPaymentTopup).Count(&ledgerCount).Error; err != nil {
t.Fatal(err)
}
if ledgerCount != 1 {
t.Fatalf("payment ledger entries = %d", ledgerCount)
}
conflictingEvidence := evidence
conflictingEvidence.ProviderTradeNo = "wechat-trade-other"
if _, _, err := repo.CompletePaymentOrder(order.ProviderID, order.MerchantOrderNo, conflictingEvidence); !errors.Is(err, ErrPaymentEvidenceMismatch) {
t.Fatalf("conflicting trade number error = %v, want %v", err, ErrPaymentEvidenceMismatch)
}
}
func TestActivePaymentOrderCountForPluginScopesVersionAndState(t *testing.T) {
db := openPaymentTestDB(t)
repo := New(db)
orders := []model.PaymentOrder{
{ID: "plugin-v1-pending", UserID: "u1", IdempotencyKey: "i1", MerchantOrderNo: "m1", ProviderID: "p1", PluginID: "plugin", PluginVersion: "1.0.0", Status: model.PaymentOrderPending},
{ID: "plugin-legacy-pending", UserID: "u5", IdempotencyKey: "i5", MerchantOrderNo: "m5", ProviderID: "p1", PluginID: "plugin", Status: model.PaymentOrderPending},
{ID: "plugin-v1-credited", UserID: "u2", IdempotencyKey: "i2", MerchantOrderNo: "m2", ProviderID: "p1", PluginID: "plugin", PluginVersion: "1.0.0", Status: model.PaymentOrderCredited},
{ID: "plugin-v2-pending", UserID: "u3", IdempotencyKey: "i3", MerchantOrderNo: "m3", ProviderID: "p1", PluginID: "plugin", PluginVersion: "2.0.0", Status: model.PaymentOrderPending},
{ID: "other-pending", UserID: "u4", IdempotencyKey: "i4", MerchantOrderNo: "m4", ProviderID: "p2", PluginID: "other", PluginVersion: "1.0.0", Status: model.PaymentOrderPending},
}
if err := db.Create(&orders).Error; err != nil {
t.Fatal(err)
}
count, err := repo.ActivePaymentOrderCountForPlugin("plugin", "1.0.0")
if err != nil || count != 2 {
t.Fatalf("version-scoped active count = %d, error = %v", count, err)
}
count, err = repo.ActivePaymentOrderCountForPlugin("plugin", "")
if err != nil || count != 3 {
t.Fatalf("all-version active count = %d, error = %v", count, err)
}
}
func TestCompletePaymentOrderRejectsAmountMismatchWithoutGrant(t *testing.T) {
db := openPaymentTestDB(t)
repo := New(db)
order := model.PaymentOrder{
ID: "payment-order-2", UserID: "user-2", IdempotencyKey: "idem-2", MerchantOrderNo: "merchant-order-2",
ProductID: "product-2", ProductName: "200 积分", ProviderID: "alipay-page-pay", PluginID: "plugin-2",
ProviderConfigID: "config-2", ProviderConfigVersion: 1, AmountFen: 200, Currency: "CNY",
CreditsMicrocredits: 200_000_000, Status: model.PaymentOrderPending, ExpiresAt: time.Now().Add(time.Hour),
}
if err := db.Create(&order).Error; err != nil {
t.Fatal(err)
}
_, _, err := repo.CompletePaymentOrder("alipay-page-pay", "merchant-order-2", PaymentEvidence{
ProviderTradeNo: "alipay-trade-2", ProviderStatus: "TRADE_SUCCESS", AmountFen: 199, Currency: "CNY",
})
if !errors.Is(err, ErrPaymentEvidenceMismatch) {
t.Fatalf("error = %v", err)
}
var ledgerCount int64
if err := db.Model(&model.CreditLedgerEntry{}).Count(&ledgerCount).Error; err != nil {
t.Fatal(err)
}
if ledgerCount != 0 {
t.Fatalf("ledger entries = %d", ledgerCount)
}
}
func TestCompletePaymentOrderScopesTradeNumberUniquenessByProvider(t *testing.T) {
db := openPaymentTestDB(t)
repo := New(db)
orders := []model.PaymentOrder{
{
ID: "wechat-order-a", UserID: "wechat-user-a", IdempotencyKey: "wechat-idem-a", MerchantOrderNo: "wechat-merchant-order-a",
ProductID: "product", ProductName: "积分", ProviderID: "wechat-native", PluginID: "wechat-plugin",
ProviderConfigID: "wechat-config", ProviderConfigVersion: 1, AmountFen: 100, Currency: "CNY",
CreditsMicrocredits: 100, Status: model.PaymentOrderPending, ExpiresAt: time.Now().Add(time.Hour),
},
{
ID: "wechat-order-b", UserID: "wechat-user-b", IdempotencyKey: "wechat-idem-b", MerchantOrderNo: "wechat-merchant-order-b",
ProductID: "product", ProductName: "积分", ProviderID: "wechat-native", PluginID: "wechat-plugin",
ProviderConfigID: "wechat-config", ProviderConfigVersion: 1, AmountFen: 100, Currency: "CNY",
CreditsMicrocredits: 100, Status: model.PaymentOrderPending, ExpiresAt: time.Now().Add(time.Hour),
},
{
ID: "alipay-order", UserID: "alipay-user", IdempotencyKey: "alipay-idem", MerchantOrderNo: "alipay-merchant-order",
ProductID: "product", ProductName: "积分", ProviderID: "alipay-page-pay", PluginID: "alipay-plugin",
ProviderConfigID: "alipay-config", ProviderConfigVersion: 1, AmountFen: 100, Currency: "CNY",
CreditsMicrocredits: 100, Status: model.PaymentOrderPending, ExpiresAt: time.Now().Add(time.Hour),
},
}
if err := db.Create(&orders).Error; err != nil {
t.Fatal(err)
}
sharedTradeNo := "shared-provider-trade-number"
for _, order := range []model.PaymentOrder{orders[0], orders[2]} {
if _, granted, err := repo.CompletePaymentOrder(order.ProviderID, order.MerchantOrderNo, PaymentEvidence{
ProviderTradeNo: sharedTradeNo, ProviderStatus: "SUCCESS", AmountFen: order.AmountFen, Currency: order.Currency,
}); err != nil || !granted {
t.Fatalf("complete %s = granted %v, error %v", order.ProviderID, granted, err)
}
}
if _, _, err := repo.CompletePaymentOrder(orders[1].ProviderID, orders[1].MerchantOrderNo, PaymentEvidence{
ProviderTradeNo: sharedTradeNo, ProviderStatus: "SUCCESS", AmountFen: orders[1].AmountFen, Currency: orders[1].Currency,
}); !errors.Is(err, ErrPaymentTradeNoConflict) {
t.Fatalf("same-provider duplicate error = %v, want %v", err, ErrPaymentTradeNoConflict)
}
}
func TestCompletePaymentOrderRejectsInconsistentExistingLedger(t *testing.T) {
db := openPaymentTestDB(t)
repo := New(db)
order := model.PaymentOrder{
ID: "payment-order-inconsistent", UserID: "user-inconsistent", IdempotencyKey: "idem-inconsistent", MerchantOrderNo: "merchant-order-inconsistent",
ProductID: "product", ProductName: "100 积分", ProviderID: "wechat-native", PluginID: "plugin",
ProviderConfigID: "config", ProviderConfigVersion: 1, AmountFen: 100, Currency: "CNY",
CreditsMicrocredits: 100_000_000, Status: model.PaymentOrderPending, ExpiresAt: time.Now().Add(time.Hour),
}
if err := db.Create(&order).Error; err != nil {
t.Fatal(err)
}
referenceKey := "payment:" + order.ProviderID + ":" + order.MerchantOrderNo
if err := db.Create(&model.CreditLedgerEntry{
ID: "ledger-inconsistent", UserID: order.UserID, Type: model.CreditLedgerPaymentTopup,
AmountMicrocredits: order.CreditsMicrocredits, PaymentOrderID: order.ID, ReferenceKey: &referenceKey,
}).Error; err != nil {
t.Fatal(err)
}
_, granted, err := repo.CompletePaymentOrder(order.ProviderID, order.MerchantOrderNo, PaymentEvidence{
ProviderTradeNo: "wechat-trade-inconsistent", ProviderStatus: "SUCCESS", AmountFen: order.AmountFen, Currency: order.Currency,
})
if !errors.Is(err, ErrPaymentOrderStateConflict) {
t.Fatalf("error = %v, want %v", err, ErrPaymentOrderStateConflict)
}
if granted {
t.Fatal("inconsistent ledger must not report a granted credit")
}
var accountCount int64
if err := db.Model(&model.CreditAccount{}).Where("user_id = ?", order.UserID).Count(&accountCount).Error; err != nil {
t.Fatal(err)
}
if accountCount != 0 {
t.Fatalf("credit accounts = %d", accountCount)
}
}
func TestCompletePaymentOrderRejectsCreditBalanceOverflow(t *testing.T) {
db := openPaymentTestDB(t)
repo := New(db)
order := model.PaymentOrder{
ID: "payment-order-overflow", UserID: "user-overflow", IdempotencyKey: "idem-overflow", MerchantOrderNo: "merchant-order-overflow",
ProductID: "product", ProductName: "积分", ProviderID: "wechat-native", PluginID: "plugin",
ProviderConfigID: "config", ProviderConfigVersion: 1, AmountFen: 100, Currency: "CNY",
CreditsMicrocredits: 100, Status: model.PaymentOrderPending, ExpiresAt: time.Now().Add(time.Hour),
}
if err := db.Create(&order).Error; err != nil {
t.Fatal(err)
}
account := model.CreditAccount{UserID: order.UserID, AvailableMicrocredits: maxPaymentCreditBalance - 50}
if err := db.Create(&account).Error; err != nil {
t.Fatal(err)
}
_, granted, err := repo.CompletePaymentOrder(order.ProviderID, order.MerchantOrderNo, PaymentEvidence{
ProviderTradeNo: "wechat-trade-overflow", ProviderStatus: "SUCCESS", AmountFen: order.AmountFen, Currency: order.Currency,
})
if !errors.Is(err, ErrPaymentCreditOverflow) {
t.Fatalf("error = %v, want %v", err, ErrPaymentCreditOverflow)
}
if granted {
t.Fatal("overflowing credit must not report a grant")
}
if err := db.First(&account, "user_id = ?", order.UserID).Error; err != nil {
t.Fatal(err)
}
if account.AvailableMicrocredits != maxPaymentCreditBalance-50 {
t.Fatalf("available credits = %d", account.AvailableMicrocredits)
}
var ledgerCount int64
if err := db.Model(&model.CreditLedgerEntry{}).Where("payment_order_id = ?", order.ID).Count(&ledgerCount).Error; err != nil {
t.Fatal(err)
}
if ledgerCount != 0 {
t.Fatalf("payment ledger entries = %d", ledgerCount)
}
}
func TestCreatePaymentProviderConfigVersionsAreMonotonic(t *testing.T) {
db := openPaymentTestDB(t)
repo := New(db)
first := &model.PaymentProviderConfig{ID: "config-1", ProviderID: "wechat-native", PluginID: "plugin", CloseAfterMinutes: 30}
second := &model.PaymentProviderConfig{ID: "config-2", ProviderID: "wechat-native", PluginID: "plugin", CloseAfterMinutes: 60}
if err := repo.CreatePaymentProviderConfig(first); err != nil {
t.Fatal(err)
}
if err := repo.CreatePaymentProviderConfig(second); err != nil {
t.Fatal(err)
}
if first.Version != 1 || second.Version != 2 {
t.Fatalf("versions = %d, %d", first.Version, second.Version)
}
}
func TestClaimExpiredPaymentOrdersIncludesAmbiguousAndStaleClosingStates(t *testing.T) {
db := openPaymentTestDB(t)
repo := New(db)
now := time.Now()
statuses := []model.PaymentOrderStatus{
model.PaymentOrderCreated,
model.PaymentOrderPending,
model.PaymentOrderCreateFailed,
model.PaymentOrderClosing,
}
for index, status := range statuses {
order := model.PaymentOrder{
ID: "expired-order-" + string(rune('a'+index)), UserID: "expired-user", IdempotencyKey: "expired-idem-" + string(rune('a'+index)),
MerchantOrderNo: "expired-merchant-order-0000000" + string(rune('a'+index)), ProductID: "product", ProductName: "积分",
ProviderID: "wechat-native", PluginID: "plugin", ProviderConfigID: "config", ProviderConfigVersion: 1,
AmountFen: 100, Currency: "CNY", CreditsMicrocredits: 1_000_000, Status: status,
ExpiresAt: now.Add(-time.Minute), UpdatedAt: now.Add(-3 * time.Minute),
}
if err := db.Create(&order).Error; err != nil {
t.Fatal(err)
}
}
freshClosing := model.PaymentOrder{
ID: "fresh-closing", UserID: "expired-user", IdempotencyKey: "fresh-closing", MerchantOrderNo: "fresh-closing-merchant-000000000",
ProductID: "product", ProductName: "积分", ProviderID: "wechat-native", PluginID: "plugin", ProviderConfigID: "config", ProviderConfigVersion: 1,
AmountFen: 100, Currency: "CNY", CreditsMicrocredits: 1_000_000, Status: model.PaymentOrderClosing,
ExpiresAt: now.Add(-time.Minute), UpdatedAt: now,
}
if err := db.Create(&freshClosing).Error; err != nil {
t.Fatal(err)
}
claimed, err := repo.ClaimExpiredPaymentOrders(10)
if err != nil {
t.Fatal(err)
}
if len(claimed) != len(statuses) {
t.Fatalf("claimed %d orders, want %d", len(claimed), len(statuses))
}
for _, order := range claimed {
if order.Status != model.PaymentOrderClosing {
t.Fatalf("claimed status = %s", order.Status)
}
}
}
func TestPaymentOrdersNeedingQueryIncludesAmbiguousCreationStates(t *testing.T) {
db := openPaymentTestDB(t)
repo := New(db)
statuses := []model.PaymentOrderStatus{
model.PaymentOrderCreated,
model.PaymentOrderPending,
model.PaymentOrderCreateFailed,
model.PaymentOrderClosing,
model.PaymentOrderCredited,
model.PaymentOrderClosed,
}
for index, status := range statuses {
suffix := string(rune('a' + index))
order := model.PaymentOrder{
ID: "query-order-" + suffix, UserID: "query-user", IdempotencyKey: "query-idem-" + suffix,
MerchantOrderNo: "query-merchant-order-000000000" + suffix, ProductID: "product", ProductName: "积分",
ProviderID: "wechat-native", PluginID: "plugin", ProviderConfigID: "config", ProviderConfigVersion: 1,
AmountFen: 100, Currency: "CNY", CreditsMicrocredits: 1_000_000, Status: status,
ExpiresAt: time.Now().Add(time.Hour),
}
if err := db.Create(&order).Error; err != nil {
t.Fatal(err)
}
}
orders, err := repo.PaymentOrdersNeedingQuery(time.Now(), 20)
if err != nil {
t.Fatal(err)
}
wanted := map[model.PaymentOrderStatus]bool{
model.PaymentOrderCreated: true, model.PaymentOrderPending: true, model.PaymentOrderCreateFailed: true,
}
if len(orders) != len(wanted) {
t.Fatalf("query candidates = %d, want %d", len(orders), len(wanted))
}
for _, order := range orders {
if !wanted[order.Status] {
t.Fatalf("unexpected query candidate status %s", order.Status)
}
}
}
func TestUnresolvedPaymentOrderCandidateCountOverlapping(t *testing.T) {
db := openPaymentTestDB(t)
repo := New(db)
start := time.Date(2026, 9, 1, 0, 0, 0, 0, time.FixedZone("CST", 8*60*60))
closedOrder := model.PaymentOrder{
ID: "candidate-order", UserID: "candidate-user", IdempotencyKey: "candidate-idem", MerchantOrderNo: "candidate-merchant-order-0000000",
ProductID: "product", ProductName: "积分", ProviderID: "alipay-page-pay", PluginID: "plugin", ProviderConfigID: "config", ProviderConfigVersion: 1,
AmountFen: 100, Currency: "CNY", CreditsMicrocredits: 1_000_000, Status: model.PaymentOrderClosed,
ExpiresAt: start.Add(time.Hour), CreatedAt: start.Add(-time.Hour),
}
pendingOrder := closedOrder
pendingOrder.ID = "pending-candidate-order"
pendingOrder.IdempotencyKey = "pending-candidate-idem"
pendingOrder.MerchantOrderNo = "pending-candidate-merchant-00000"
pendingOrder.Status = model.PaymentOrderPending
if err := db.Create(&closedOrder).Error; err != nil {
t.Fatal(err)
}
if err := db.Create(&pendingOrder).Error; err != nil {
t.Fatal(err)
}
count, err := repo.UnresolvedPaymentOrderCandidateCountOverlapping("alipay-page-pay", start, start.AddDate(0, 0, 1))
if err != nil {
t.Fatal(err)
}
if count != 1 {
t.Fatalf("unresolved candidate count = %d", count)
}
}
func TestAdminPaymentOrdersSearchUserIdentity(t *testing.T) {
db := openPaymentTestDB(t)
if err := db.AutoMigrate(&model.User{}); err != nil {
t.Fatal(err)
}
users := []model.User{
{ID: "user-alice", Username: "alice", DisplayName: "小林", Email: "alice@example.com"},
{ID: "user-bob", Username: "bob", DisplayName: "小林", Email: "bob@example.com"},
}
if err := db.Create(&users).Error; err != nil {
t.Fatal(err)
}
orders := []model.PaymentOrder{
{ID: "a", UserID: "user-alice", MerchantOrderNo: "merchant-a", IdempotencyKey: "a", Status: model.PaymentOrderPending},
{ID: "b", UserID: "user-bob", MerchantOrderNo: "merchant-b", IdempotencyKey: "b", Status: model.PaymentOrderClosed},
{ID: "c", UserID: "missing-user", MerchantOrderNo: "merchant-c", IdempotencyKey: "c", Status: model.PaymentOrderClosed},
}
tradeNo := "trade-c"
orders[2].ProviderTradeNo = &tradeNo
if err := db.Create(&orders).Error; err != nil {
t.Fatal(err)
}
repo := New(db)
for _, tc := range []struct {
keyword, status string
total int64
}{
{"ALICE@EXAMPLE.COM", "", 1}, {"alice", "", 1}, {"小林", "", 2},
{"小林", "pending", 1}, {"alice", "closed", 0}, {"user-bob", "", 1},
{"merchant-c", "", 1}, {"trade-c", "", 1}, {"missing-user", "", 1}, {"", "", 3},
} {
t.Run(tc.keyword+"/"+tc.status, func(t *testing.T) {
items, total, err := repo.AdminPaymentOrders(PaymentOrderFilter{Status: tc.status, Keyword: tc.keyword}, 1, 0)
if err != nil || total != tc.total || len(items) > 1 {
t.Fatalf("got items=%d total=%d err=%v; want total=%d", len(items), total, err, tc.total)
}
})
}
identities, err := repo.UsersByIDs([]string{"user-alice", "missing-user"})
if err != nil || len(identities) != 1 || identities["user-alice"].Email != "alice@example.com" {
t.Fatalf("identities=%v err=%v", identities, err)
}
}
func openPaymentTestDB(t *testing.T) *gorm.DB {
t.Helper()
db, err := gorm.Open(sqlite.Open("file:"+t.Name()+"?mode=memory&cache=shared&_busy_timeout=5000"), &gorm.Config{})
if err != nil {
t.Fatal(err)
}
if err := db.AutoMigrate(
&model.CreditAccount{}, &model.CreditLedgerEntry{}, &model.TopupProduct{},
&model.PaymentProviderConfig{}, &model.PaymentOrder{}, &model.PaymentNotification{},
); err != nil {
t.Fatal(err)
}
return db
}