package store import ( "errors" "fmt" "sync" "testing" "time" "github.com/glebarez/sqlite" "gorm.io/gorm" "gorm.io/gorm/logger" "github.com/wangjia/pay/internal/model" ) func seedPaid(t *testing.T, os *OrderStore, outNo string, amount int64) { t.Helper() if err := os.CreateOrder(&model.OrderV2{ OutTradeNo: outNo, BizSystem: "pangolin", BizCode: "pro_year", AmountMinor: amount, Currency: "CNY", Status: model.OrderPaidV2, }); err != nil { t.Fatal(err) } if err := os.CreateAttempt(&model.Attempt{ OutTradeNo: outNo, Channel: "alipay", ProviderRef: outNo, AmountMinor: amount, Currency: "CNY", Status: model.AttemptPaid, }); err != nil { t.Fatal(err) } } func TestRefundStoreLifecycle(t *testing.T) { db := model.OpenTestDB(t) os := NewOrderStore(db) rs := NewRefundStore(db) seedPaid(t, os, "PAY-1", 19900) // PaidAttempt 定位原交易 att, err := os.PaidAttempt("PAY-1") if err != nil || att.Channel != "alipay" || att.ProviderRef != "PAY-1" { t.Fatalf("PaidAttempt = %+v, %v", att, err) } // 建两笔部分退款(processing) for _, id := range []string{"rf-1", "rf-2"} { if err := rs.CreateRefund(&model.Refund{ RefundID: id, OutTradeNo: "PAY-1", AttemptProviderRef: "PAY-1", AmountMinor: 5000, Currency: "CNY", Status: model.RefundProcessing, InitiatedBy: "business", }); err != nil { t.Fatal(err) } } // 非失败之和 = 10000(占额度) if sum, _ := rs.RefundSum("PAY-1", model.RefundRequested, model.RefundProcessing, model.RefundManualPending, model.RefundSucceeded); sum != 10000 { t.Fatalf("reserved sum = %d want 10000", sum) } // rf-1 翻 succeeded(条件正确) ok, err := rs.MarkRefundStatus("rf-1", model.RefundProcessing, model.RefundSucceeded, "ali-refund-1", time.Now()) if err != nil || !ok { t.Fatalf("mark rf-1 = %v, %v", ok, err) } // 再翻一次(from 已不符)→ no-op if ok, _ := rs.MarkRefundStatus("rf-1", model.RefundProcessing, model.RefundSucceeded, "x", time.Now()); ok { t.Fatal("重复翻转应 no-op") } r1, _ := rs.GetRefund("rf-1") if r1.Status != model.RefundSucceeded || r1.ProviderRefundRef != "ali-refund-1" || r1.CompletedAt == nil { t.Fatalf("rf-1 = %+v", r1) } // 部分退:succeeded 之和 5000 < 19900 → partially_refunded succ, _ := rs.RefundSum("PAY-1", model.RefundSucceeded) if flipped, _ := os.ApplyRefundToOrder("PAY-1", succ >= 19900); !flipped { t.Fatal("ApplyRefundToOrder 应翻转") } if o, _ := os.GetOrder("PAY-1"); o.Status != model.OrderPartRefundedV2 { t.Fatalf("order status = %s want partially_refunded", o.Status) } // rf-2 也成功 → 总退 10000 仍 < 19900,保持 partially_refunded(非 fully) _, _ = rs.MarkRefundStatus("rf-2", model.RefundProcessing, model.RefundSucceeded, "ali-refund-2", time.Now()) succ, _ = rs.RefundSum("PAY-1", model.RefundSucceeded) _, _ = os.ApplyRefundToOrder("PAY-1", succ >= 19900) if o, _ := os.GetOrder("PAY-1"); o.Status != model.OrderPartRefundedV2 { t.Fatalf("order status = %s want partially_refunded", o.Status) } } func TestApplyRefundToOrderFully(t *testing.T) { db := model.OpenTestDB(t) os := NewOrderStore(db) seedPaid(t, os, "PAY-2", 10000) if _, err := os.ApplyRefundToOrder("PAY-2", true); err != nil { t.Fatal(err) } if o, _ := os.GetOrder("PAY-2"); o.Status != model.OrderRefundedV2 { t.Fatalf("status = %s want refunded", o.Status) } } func TestListManualPending(t *testing.T) { db := model.OpenTestDB(t) rs := NewRefundStore(db) _ = rs.CreateRefund(&model.Refund{RefundID: "rf-m1", OutTradeNo: "C-1", AmountMinor: 1, Currency: "USDT", Status: model.RefundManualPending}) _ = rs.CreateRefund(&model.Refund{RefundID: "rf-p1", OutTradeNo: "C-2", AmountMinor: 1, Currency: "USDT", Status: model.RefundProcessing}) list, err := rs.ListManualPending(50) if err != nil || len(list) != 1 || list[0].RefundID != "rf-m1" { t.Fatalf("manual pending = %+v, %v", list, err) } } // TestCreateRefundGuardedSequential 顺序场景:60 通过、再 50 超退拒(60+50=110>100)、 // 再 40 通过(60+40=100 打满),验证守卫在非并发路径下的基本判定与求和口径(非 failed)。 func TestCreateRefundGuardedSequential(t *testing.T) { db := model.OpenTestDB(t) os := NewOrderStore(db) rs := NewRefundStore(db) seedPaid(t, os, "PAY-G1", 10000) // 100.00 ok, err := rs.CreateRefundGuarded(&model.Refund{ RefundID: "g1-a", OutTradeNo: "PAY-G1", AttemptProviderRef: "PAY-G1", AmountMinor: 6000, Currency: "CNY", Status: model.RefundProcessing, InitiatedBy: "business", }, 10000) if err != nil || !ok { t.Fatalf("g1-a: ok=%v err=%v want true,nil", ok, err) } ok, err = rs.CreateRefundGuarded(&model.Refund{ RefundID: "g1-b", OutTradeNo: "PAY-G1", AttemptProviderRef: "PAY-G1", AmountMinor: 5000, Currency: "CNY", Status: model.RefundProcessing, InitiatedBy: "business", }, 10000) if err != nil || ok { t.Fatalf("g1-b: ok=%v err=%v want false,nil(超退应拒)", ok, err) } ok, err = rs.CreateRefundGuarded(&model.Refund{ RefundID: "g1-c", OutTradeNo: "PAY-G1", AttemptProviderRef: "PAY-G1", AmountMinor: 4000, Currency: "CNY", Status: model.RefundProcessing, InitiatedBy: "business", }, 10000) if err != nil || !ok { t.Fatalf("g1-c: ok=%v err=%v want true,nil", ok, err) } sum, err := rs.RefundSum("PAY-G1", model.RefundRequested, model.RefundProcessing, model.RefundManualPending, model.RefundSucceeded) if err != nil || sum != 10000 { t.Fatalf("reserved sum = %d, err=%v want 10000", sum, err) } // 被拒的 g1-b 不应落库 if _, err := rs.GetRefund("g1-b"); !errors.Is(err, ErrRefundNotFound) { t.Fatalf("g1-b 应未落库,GetRefund err=%v", err) } } // openFileGuardedDB 开一个 t.TempDir 下的文件型 sqlite(而非 model.OpenTestDB 的 // in-memory cache=shared),确保并发 goroutine 走真正独立的连接/文件锁路径来 // 验证 CreateRefundGuarded 的串行化 —— DSN 同样带 _txlock=immediate。 func openFileGuardedDB(t *testing.T) *gorm.DB { t.Helper() dsn := fmt.Sprintf("file:%s/guard.db?_txlock=immediate&_pragma=busy_timeout(5000)", t.TempDir()) db, err := gorm.Open(sqlite.Open(dsn), &gorm.Config{Logger: logger.Default.LogMode(logger.Silent), TranslateError: true}) if err != nil { t.Fatalf("open file guard db: %v", err) } if err := db.AutoMigrate(&model.OrderV2{}, &model.Attempt{}, &model.Account{}, &model.Refund{}, &model.WebhookDelivery{}, &model.Product{}, &model.ProductPrice{}); err != nil { t.Fatalf("migrate: %v", err) } sqlDB, _ := db.DB() t.Cleanup(func() { _ = sqlDB.Close() }) return db } // TestCreateRefundGuardedConcurrentExactlyOneWins 两个 goroutine 同时对同一笔 // 100.00 已付订单各申请 60.00 退款(合计 120 > 100,必超退)。事务化守卫(锁订单行 + // 事务内求和 + 校验 + 插入)必须让恰好一个成功、另一个被拒 —— 若把守卫换回「先 // RefundSum 再 CreateRefund」的裸读写两段式(Task 3 计划里的朴素调用法),两个 // goroutine 都可能读到 reserved=0、都通过校验、都插入成功,变成两个 true(超退)。 // 本测试即用来钉住这个回归:TDD 上手动验证过 —— 把 CreateRefundGuarded 换成裸 // RefundSum(...)+CreateRefund(...) 两步、不用事务/锁,本测试会因为「两个 true」而 // FAIL(count==2),证明测试确实在防这一类竞态,而不是摆设。 func TestCreateRefundGuardedConcurrentExactlyOneWins(t *testing.T) { db := openFileGuardedDB(t) os := NewOrderStore(db) rs := NewRefundStore(db) seedPaid(t, os, "PAY-G2", 10000) // 100.00 var wg sync.WaitGroup results := make([]bool, 2) errs := make([]error, 2) ids := []string{"g2-a", "g2-b"} for i := 0; i < 2; i++ { wg.Add(1) go func(i int) { defer wg.Done() ok, err := rs.CreateRefundGuarded(&model.Refund{ RefundID: ids[i], OutTradeNo: "PAY-G2", AttemptProviderRef: "PAY-G2", AmountMinor: 6000, Currency: "CNY", Status: model.RefundProcessing, InitiatedBy: "business", }, 10000) results[i] = ok errs[i] = err }(i) } wg.Wait() trueCount := 0 for i, ok := range results { // 允许 SQLite 忙锁重试耗尽等基础设施性错误(busy_timeout 已设为 5s 通常不会 // 触发),但不允许"两个都成功"的超退;若出现非预期错误直接报失败方便定位。 if errs[i] != nil { t.Fatalf("goroutine %d unexpected error: %v", i, errs[i]) } if ok { trueCount++ } } if trueCount != 1 { t.Fatalf("trueCount = %d, want 1 (results=%v)", trueCount, results) } sum, err := rs.RefundSum("PAY-G2", model.RefundRequested, model.RefundProcessing, model.RefundManualPending, model.RefundSucceeded) if err != nil || sum != 6000 { t.Fatalf("reserved sum = %d, err=%v want 6000(仅一笔落库)", sum, err) } }