package watcher import ( "context" "testing" "time" "github.com/wangjia/pangolin/pay/internal/store" "github.com/wangjia/pangolin/pay/internal/tron" ) const recvAddr = "TRecv00000000000000000000000000000A" type mockFetcher struct{ transfers []tron.Transfer } func (f *mockFetcher) IncomingTransfers(_ context.Context, _ string) ([]tron.Transfer, error) { return f.transfers, nil } func memStore(t *testing.T) *store.Store { t.Helper() st, err := store.Open(":memory:") if err != nil { t.Fatalf("store: %v", err) } t.Cleanup(func() { _ = st.Close() }) return st } func seed(t *testing.T, st *store.Store, no string, amount int64, created time.Time) { t.Helper() o := &store.Order{ OrderNo: no, UserRef: "u", SKU: "pro", ExpectAmount: amount, Address: recvAddr, Status: store.StatusPending, CreatedAt: created, ExpiresAt: created.Add(time.Hour), } if err := st.CreateOrder(context.Background(), o); err != nil { t.Fatalf("seed: %v", err) } } func TestWatcherMatchesByAmountAndTime(t *testing.T) { st := memStore(t) ctx := context.Background() now := time.Unix(1_700_000_100, 0) created := now.Add(-5 * time.Minute) seed(t, st, "PAY1", 5_000017, created) seed(t, st, "PAY2", 5_000018, created) fetch := &mockFetcher{transfers: []tron.Transfer{ {TxID: "tx1", To: recvAddr, Value: 5_000017, BlockTs: created.Add(time.Minute).Unix()}, }} w := New(st, fetch, recvAddr, nil) w.now = func() time.Time { return now } if err := w.Tick(ctx); err != nil { t.Fatalf("tick: %v", err) } o1, _ := st.GetOrder(ctx, "PAY1") if o1.Status != store.StatusPaid || o1.TxID != "tx1" { t.Fatalf("PAY1 %s/%s", o1.Status, o1.TxID) } o2, _ := st.GetOrder(ctx, "PAY2") if o2.Status != store.StatusPending { t.Fatalf("PAY2 should stay pending, got %s", o2.Status) } if err := w.Tick(ctx); err != nil { // idempotent t.Fatalf("tick2: %v", err) } o1, _ = st.GetOrder(ctx, "PAY1") if o1.Status != store.StatusPaid || o1.TxID != "tx1" { t.Fatal("idempotency broken") } } func TestWatcherWrongAmountIsOrphan(t *testing.T) { st := memStore(t) ctx := context.Background() now := time.Unix(1_700_000_100, 0) created := now.Add(-5 * time.Minute) seed(t, st, "PAY1", 5_000017, created) fetch := &mockFetcher{transfers: []tron.Transfer{ {TxID: "tx-wrong", To: recvAddr, Value: 5_000000, BlockTs: created.Add(time.Minute).Unix()}, }} w := New(st, fetch, recvAddr, nil) w.now = func() time.Time { return now } _ = w.Tick(ctx) o, _ := st.GetOrder(ctx, "PAY1") if o.Status != store.StatusPending { t.Fatalf("PAY1 should stay pending, got %s", o.Status) } if h, _ := st.TxHandled(ctx, "tx-wrong"); !h { t.Fatal("wrong-amount payment should be recorded as orphan") } } func TestWatcherLatePaymentDoesNotMatchNewOrder(t *testing.T) { // Order1 (amount 5_000017) expired; a NEW order (amount 5_000018) is now active // on the SAME address. A late payment of the OLD amount must NOT match the new // order (different amount) -> orphan. st := memStore(t) ctx := context.Background() now := time.Unix(1_700_000_500, 0) seed(t, st, "PAY2", 5_000018, now.Add(-time.Minute)) fetch := &mockFetcher{transfers: []tron.Transfer{ {TxID: "tx-late", To: recvAddr, Value: 5_000017, BlockTs: now.Unix()}, }} w := New(st, fetch, recvAddr, nil) w.now = func() time.Time { return now } _ = w.Tick(ctx) o2, _ := st.GetOrder(ctx, "PAY2") if o2.Status != store.StatusPending { t.Fatalf("PAY2 must not be matched by a wrong-amount late payment, got %s", o2.Status) } if h, _ := st.TxHandled(ctx, "tx-late"); !h { t.Fatal("late payment should be orphan") } } func TestWatcherIgnoresPaymentBeforeOrder(t *testing.T) { // A payment whose block time is BEFORE the order was created must not match // (guards address reuse: prior balance / old tx). st := memStore(t) ctx := context.Background() now := time.Unix(1_700_000_500, 0) created := now.Add(-2 * time.Minute) seed(t, st, "PAY1", 5_000017, created) fetch := &mockFetcher{transfers: []tron.Transfer{ {TxID: "tx-old", To: recvAddr, Value: 5_000017, BlockTs: created.Add(-time.Minute).Unix()}, }} w := New(st, fetch, recvAddr, nil) w.now = func() time.Time { return now } _ = w.Tick(ctx) o, _ := st.GetOrder(ctx, "PAY1") if o.Status != store.StatusPending { t.Fatalf("payment before order must not match, got %s", o.Status) } if h, _ := st.TxHandled(ctx, "tx-old"); !h { t.Fatal("pre-order payment should be orphan") } } func TestWatcherExpires(t *testing.T) { st := memStore(t) ctx := context.Background() now := time.Unix(1_700_000_500, 0) o := &store.Order{ OrderNo: "OLD", UserRef: "u", SKU: "pro", ExpectAmount: 1, Address: recvAddr, Status: store.StatusPending, CreatedAt: now.Add(-time.Hour), ExpiresAt: now.Add(-time.Minute), } _ = st.CreateOrder(ctx, o) w := New(st, &mockFetcher{}, recvAddr, nil) w.now = func() time.Time { return now } _ = w.Tick(ctx) got, _ := st.GetOrder(ctx, "OLD") if got.Status != store.StatusExpired { t.Fatalf("want expired, got %s", got.Status) } }