mirror of
https://github.com/hanzoai/commerce.git
synced 2026-10-30 15:08:42 +00:00
159 lines
5.2 KiB
Go
159 lines
5.2 KiB
Go
package depositledger
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"fmt"
|
|
"sync"
|
|
"testing"
|
|
|
|
"github.com/hanzoai/commerce/billing/creditledger"
|
|
"github.com/hanzoai/commerce/billing/depositwatch"
|
|
"github.com/hanzoai/commerce/util/test/ae"
|
|
)
|
|
|
|
// ledger is the host's spendable ledger as commerce sees it when embedded: it
|
|
// dedups on the idempotency key within one (org, test) ledger, the way the real
|
|
// host does. Only Credit is used here; the embedded interface answers nothing else.
|
|
type ledger struct {
|
|
creditledger.CreditLedger
|
|
mu sync.Mutex
|
|
fail error
|
|
seen map[string]bool
|
|
posted []creditledger.CreditInput
|
|
}
|
|
|
|
func (l *ledger) Credit(_ context.Context, in creditledger.CreditInput) (string, int64, error) {
|
|
l.mu.Lock()
|
|
defer l.mu.Unlock()
|
|
if l.fail != nil {
|
|
return "", 0, l.fail
|
|
}
|
|
key := fmt.Sprintf("%s|%t|%s", in.Org, in.Test, in.IdempotencyKey)
|
|
if !l.seen[key] {
|
|
l.seen[key] = true
|
|
l.posted = append(l.posted, in)
|
|
}
|
|
return "tx", 0, nil
|
|
}
|
|
|
|
func inject(t *testing.T) *ledger {
|
|
t.Helper()
|
|
l := &ledger{seen: map[string]bool{}}
|
|
creditledger.Set(l)
|
|
t.Cleanup(func() { creditledger.Set(nil) })
|
|
return l
|
|
}
|
|
|
|
func deposit(org, subject string) depositwatch.Credit {
|
|
return depositwatch.Credit{
|
|
Org: org, Subject: subject, Chain: "ethereum", Token: "usdc",
|
|
AmountCents: 2550, Units: "25500000", PegRate: "1.00",
|
|
TxHash: "0xfeedface", EventIndex: 4, Block: 980, Confirmations: 21,
|
|
DedupKey: "ethereum:0xfeedface:4",
|
|
}
|
|
}
|
|
|
|
// Embedded in a host, the balance a customer spends is the host's ledger. A
|
|
// confirmed deposit must fund it, once, as cash, at the payer's own account,
|
|
// however many passes and replicas see the same transfer.
|
|
func TestADepositFundsTheSpendableLedgerOnceAsCash(t *testing.T) {
|
|
c := ae.NewContext()
|
|
defer c.Close()
|
|
const org, subject = "acme", "acme/alice"
|
|
seedOrg(t, org)
|
|
l := inject(t)
|
|
|
|
for pass := 1; pass <= 4; pass++ {
|
|
if _, err := (intentStore{}).Credit(context.Background(), deposit(org, subject)); err != nil {
|
|
t.Fatalf("pass %d: %v", pass, err)
|
|
}
|
|
}
|
|
if len(l.posted) != 1 {
|
|
t.Fatalf("the ledger took %d credits for one transfer, want 1", len(l.posted))
|
|
}
|
|
got := l.posted[0]
|
|
if got.Org != org || got.Subject != subject || got.AmountCents != 2550 || got.Currency != "usd" {
|
|
t.Errorf("funded %+v, want org %q subject %q 2550 usd", got, org, subject)
|
|
}
|
|
if !got.Cash {
|
|
t.Errorf("a customer's own transfer must fund cash, not minted credit")
|
|
}
|
|
if got.IdempotencyKey != depositTag+":ethereum:0xfeedface:4" || got.Tag != depositTag {
|
|
t.Errorf("key %q tag %q: the key must be the on-chain event so every path credits it once", got.IdempotencyKey, got.Tag)
|
|
}
|
|
if int64(balance(t, org, subject).Balance) != 2550 {
|
|
t.Errorf("commerce's own row is still the record of the deposit")
|
|
}
|
|
}
|
|
|
|
// A ledger that refuses must stop the pass before anything says the deposit was
|
|
// handled: no local row, so the next pass tries again rather than skipping money.
|
|
func TestALedgerFailureLeavesTheDepositToTheNextPass(t *testing.T) {
|
|
c := ae.NewContext()
|
|
defer c.Close()
|
|
const org, subject = "acme", "acme/alice"
|
|
seedOrg(t, org)
|
|
l := inject(t)
|
|
l.fail = errors.New("ledger down")
|
|
|
|
if _, err := (intentStore{}).Credit(context.Background(), deposit(org, subject)); err == nil {
|
|
t.Fatal("a ledger failure must fail the credit")
|
|
}
|
|
if got := int64(balance(t, org, subject).Balance); got != 0 {
|
|
t.Fatalf("a row was written (%d cents) for money the ledger never took: the next pass would skip it", got)
|
|
}
|
|
|
|
l.fail = nil
|
|
written, err := (intentStore{}).Credit(context.Background(), deposit(org, subject))
|
|
if err != nil || !written || len(l.posted) != 1 {
|
|
t.Fatalf("retry: written=%v err=%v posted=%d, want true/nil/1", written, err, len(l.posted))
|
|
}
|
|
}
|
|
|
|
// A deposit recorded before the ledger credit existed is money received and never
|
|
// made spendable. Seeing it again funds it.
|
|
func TestAnEarlierRowIsFundedWhenSeenAgain(t *testing.T) {
|
|
c := ae.NewContext()
|
|
defer c.Close()
|
|
const org, subject = "acme", "acme/alice"
|
|
seedOrg(t, org)
|
|
|
|
if _, err := (intentStore{}).Credit(context.Background(), deposit(org, subject)); err != nil {
|
|
t.Fatalf("standalone credit: %v", err)
|
|
}
|
|
l := inject(t)
|
|
written, err := (intentStore{}).Credit(context.Background(), deposit(org, subject))
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if written {
|
|
t.Errorf("the local row already existed; nothing new was written there")
|
|
}
|
|
if len(l.posted) != 1 || !l.posted[0].Cash {
|
|
t.Fatalf("the earlier deposit was not funded: %+v", l.posted)
|
|
}
|
|
}
|
|
|
|
// A replay whose amount was recomputed differently replays the credit already
|
|
// made, so the ledger never refuses the key and stalls the asset.
|
|
func TestAReplayUsesTheAmountAlreadyRecorded(t *testing.T) {
|
|
c := ae.NewContext()
|
|
defer c.Close()
|
|
const org, subject = "acme", "acme/alice"
|
|
seedOrg(t, org)
|
|
l := inject(t)
|
|
if _, err := (intentStore{}).Credit(context.Background(), deposit(org, subject)); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
again := deposit(org, subject)
|
|
again.AmountCents = 2400 // a fee changed across a restart
|
|
l.seen = map[string]bool{}
|
|
if _, err := (intentStore{}).Credit(context.Background(), again); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if got := l.posted[len(l.posted)-1].AmountCents; got != 2550 {
|
|
t.Fatalf("the replay asked the ledger for %d cents, want the recorded 2550", got)
|
|
}
|
|
}
|