Files
pangolin/pay/internal/store/store_test.go
T
wangjia 4bb92209ca feat(pay): 收款引擎 —— 建单/派生地址 + TronGrid watcher 侦测到账(#34/34A Phase B.3+C)
- store(SQLite,modernc 纯 Go):pay_orders + addr_cursor(HD 派生游标,地址不复用);
  建单/查单/ListPending/MarkPaid(幂等,仅 pending→paid)/MarkExpired。
- pay 服务:CreateOrder 每单 NextAddrIndex→从 xpub watch-only 派生唯一收款地址→写 pending 单(TTL 15min)。
- tron:TronGrid 客户端读已确认 TRC20 到账(only_confirmed + USDT 合约,micro-USDT 整数)。
- watcher:Tick 先过期逾期单,再对每个 pending 单查到账、金额≥期望→MarkPaid;幂等(同 tx 只认一次)、
  网络错误跳过下轮重试;Loop 定时轮询。
- httpapi:POST /order、GET /order/{orderNo}、/healthz;cmd/paywatch 用 env 装配 + 优雅退出。
- 测试:store/service/watcher(mock TronGrid)/httpapi 全绿——建单派生地址正确、到账侦测、
  欠额不认、幂等、超时过期、404/400。热服务无私钥。
- README:安全模型 + Phase A 离线备钱包步骤 + 运行/API/测试。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-09 02:57:47 +08:00

89 lines
2.5 KiB
Go

package store
import (
"context"
"testing"
"time"
)
func openMem(t *testing.T) *Store {
t.Helper()
s, err := Open(":memory:")
if err != nil {
t.Fatalf("open: %v", err)
}
t.Cleanup(func() { _ = s.Close() })
return s
}
func TestNextAddrIndexMonotonic(t *testing.T) {
s := openMem(t)
ctx := context.Background()
for want := uint32(0); want < 5; want++ {
got, err := s.NextAddrIndex(ctx)
if err != nil {
t.Fatalf("next: %v", err)
}
if got != want {
t.Fatalf("index got %d want %d", got, want)
}
}
}
func TestOrderRoundtripAndMarkPaidIdempotent(t *testing.T) {
s := openMem(t)
ctx := context.Background()
now := time.Unix(1_700_000_000, 0)
o := &Order{
OrderNo: "PAY1", SKU: "pro-year", ExpectAmount: 5_000000, AddrIndex: 0,
Address: "TUEZSdKsoDHQMeZwihtdoBiN46zxhGWYdH", Status: StatusPending,
CreatedAt: now, ExpiresAt: now.Add(15 * time.Minute),
}
if err := s.CreateOrder(ctx, o); err != nil {
t.Fatalf("create: %v", err)
}
got, err := s.GetOrder(ctx, "PAY1")
if err != nil {
t.Fatalf("get: %v", err)
}
if got.SKU != "pro-year" || got.ExpectAmount != 5_000000 || got.Status != StatusPending {
t.Fatalf("roundtrip mismatch: %+v", got)
}
ok, err := s.MarkPaid(ctx, "PAY1", "tx-abc")
if err != nil || !ok {
t.Fatalf("first MarkPaid ok=%v err=%v (want true,nil)", ok, err)
}
ok2, err := s.MarkPaid(ctx, "PAY1", "tx-dup")
if err != nil || ok2 {
t.Fatalf("second MarkPaid ok=%v err=%v (want false,nil — idempotent)", ok2, err)
}
got, _ = s.GetOrder(ctx, "PAY1")
if got.Status != StatusPaid || got.TxID != "tx-abc" {
t.Fatalf("after paid: status=%s tx=%s (want paid,tx-abc)", got.Status, got.TxID)
}
}
func TestMarkExpired(t *testing.T) {
s := openMem(t)
ctx := context.Background()
base := time.Unix(1_700_000_000, 0)
past := &Order{OrderNo: "old", SKU: "x", ExpectAmount: 1, Address: "T1", Status: StatusPending, CreatedAt: base, ExpiresAt: base.Add(1 * time.Minute)}
future := &Order{OrderNo: "new", SKU: "x", ExpectAmount: 1, Address: "T2", Status: StatusPending, CreatedAt: base, ExpiresAt: base.Add(1 * time.Hour)}
_ = s.CreateOrder(ctx, past)
_ = s.CreateOrder(ctx, future)
n, err := s.MarkExpired(ctx, base.Add(10*time.Minute))
if err != nil || n != 1 {
t.Fatalf("MarkExpired n=%d err=%v (want 1)", n, err)
}
oldO, _ := s.GetOrder(ctx, "old")
newO, _ := s.GetOrder(ctx, "new")
if oldO.Status != StatusExpired {
t.Fatalf("old should be expired, got %s", oldO.Status)
}
if newO.Status != StatusPending {
t.Fatalf("new should still be pending, got %s", newO.Status)
}
}