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(tc.status, 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 }