mirror of
https://github.com/hanzo-fi/payments.git
synced 2026-10-30 20:31:20 +00:00
231 lines
6.7 KiB
Go
231 lines
6.7 KiB
Go
package coinbaseprime
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"sync"
|
|
"time"
|
|
|
|
"github.com/hanzo-fi/go-libs/v5/pkg/observe/log"
|
|
"github.com/hanzo-fi/payments/ee/plugins/coinbaseprime/client"
|
|
"github.com/hanzo-fi/payments/pkg/domain/plugins"
|
|
. "github.com/onsi/ginkgo/v2"
|
|
. "github.com/onsi/gomega"
|
|
"go.uber.org/mock/gomock"
|
|
)
|
|
|
|
var _ = Describe("Coinbase Plugin ensureAssetsFresh", func() {
|
|
var (
|
|
ctrl *gomock.Controller
|
|
m *client.MockClient
|
|
plg *Plugin
|
|
)
|
|
|
|
BeforeEach(func() {
|
|
ctrl = gomock.NewController(GinkgoT())
|
|
m = client.NewMockClient(ctrl)
|
|
plg = &Plugin{
|
|
Plugin: plugins.NewBasePlugin(),
|
|
client: m,
|
|
logger: logging.NewDefaultLogger(GinkgoWriter, true, false, false),
|
|
}
|
|
})
|
|
|
|
AfterEach(func() {
|
|
ctrl.Finish()
|
|
})
|
|
|
|
It("is a no-op within the TTL window after a recent sync", func(ctx SpecContext) {
|
|
plg.currencies = map[string]int{"USD": 2}
|
|
plg.networkSymbols = map[string]string{}
|
|
plg.entityID = "entity-fresh"
|
|
plg.assetsLastSync = time.Now()
|
|
|
|
// No GetPortfolio / GetAssets expectations -- any call would fail the test.
|
|
Expect(plg.ensureAssetsFresh(ctx)).To(Succeed())
|
|
})
|
|
|
|
It("triggers a refresh when the cache is empty", func(ctx SpecContext) {
|
|
m.EXPECT().GetPortfolio(gomock.Any()).Return(
|
|
&client.PortfolioResponse{
|
|
Portfolio: client.Portfolio{EntityID: "entity-empty"},
|
|
},
|
|
nil,
|
|
)
|
|
m.EXPECT().GetAssets(gomock.Any(), "entity-empty").Return(
|
|
&client.AssetsResponse{
|
|
Assets: []client.Asset{{Symbol: "BTC", DecimalPrecision: "8"}},
|
|
},
|
|
nil,
|
|
)
|
|
|
|
Expect(plg.ensureAssetsFresh(ctx)).To(Succeed())
|
|
Expect(plg.currencies).To(HaveKey("BTC"))
|
|
Expect(plg.entityID).To(Equal("entity-empty"))
|
|
Expect(plg.assetsLastSync).ToNot(BeZero())
|
|
})
|
|
|
|
It("triggers a refresh when the cache is stale beyond the TTL", func(ctx SpecContext) {
|
|
plg.currencies = map[string]int{"USD": 2}
|
|
plg.networkSymbols = map[string]string{}
|
|
plg.entityID = "entity-stale"
|
|
plg.assetsLastSync = time.Now().Add(-2 * assetRefreshInterval)
|
|
|
|
// Portfolio is already known -- only GetAssets should be called.
|
|
m.EXPECT().GetAssets(gomock.Any(), "entity-stale").Return(
|
|
&client.AssetsResponse{
|
|
Assets: []client.Asset{{Symbol: "BTC", DecimalPrecision: "8"}},
|
|
},
|
|
nil,
|
|
)
|
|
|
|
before := plg.assetsLastSync
|
|
Expect(plg.ensureAssetsFresh(ctx)).To(Succeed())
|
|
Expect(plg.currencies).To(HaveKey("BTC"))
|
|
Expect(plg.assetsLastSync.After(before)).To(BeTrue())
|
|
})
|
|
|
|
It("does not re-fetch the portfolio once entityID is cached", func(ctx SpecContext) {
|
|
plg.currencies = map[string]int{}
|
|
plg.entityID = "entity-cached"
|
|
plg.assetsLastSync = time.Time{} // stale
|
|
|
|
// Only GetAssets -- no GetPortfolio mock means the test fails if it's called.
|
|
m.EXPECT().GetAssets(gomock.Any(), "entity-cached").Return(
|
|
&client.AssetsResponse{
|
|
Assets: []client.Asset{{Symbol: "USDC", DecimalPrecision: "6"}},
|
|
},
|
|
nil,
|
|
)
|
|
|
|
Expect(plg.ensureAssetsFresh(ctx)).To(Succeed())
|
|
})
|
|
|
|
It("collapses concurrent callers into a single GetAssets call", func(ctx SpecContext) {
|
|
plg.entityID = "entity-concurrent"
|
|
plg.assetsLastSync = time.Time{} // stale
|
|
|
|
// Gate GetAssets so both goroutines race past the fast-path check before
|
|
// the first one completes. Exactly one call is expected.
|
|
gate := make(chan struct{})
|
|
m.EXPECT().GetAssets(gomock.Any(), "entity-concurrent").DoAndReturn(
|
|
func(_ context.Context, _ string) (*client.AssetsResponse, error) {
|
|
<-gate
|
|
return &client.AssetsResponse{
|
|
Assets: []client.Asset{{Symbol: "BTC", DecimalPrecision: "8"}},
|
|
}, nil
|
|
},
|
|
).Times(1)
|
|
|
|
var wg sync.WaitGroup
|
|
errs := make(chan error, 2)
|
|
for i := 0; i < 2; i++ {
|
|
wg.Add(1)
|
|
go func() {
|
|
defer wg.Done()
|
|
errs <- plg.ensureAssetsFresh(ctx)
|
|
}()
|
|
}
|
|
// Small yield so both goroutines can enter ensureAssetsFresh.
|
|
time.Sleep(10 * time.Millisecond)
|
|
close(gate)
|
|
wg.Wait()
|
|
close(errs)
|
|
for err := range errs {
|
|
Expect(err).To(Succeed())
|
|
}
|
|
})
|
|
|
|
It("propagates GetAssets errors and leaves stale state untouched", func(ctx SpecContext) {
|
|
plg.currencies = map[string]int{"USD": 2}
|
|
plg.entityID = "entity-err"
|
|
plg.assetsLastSync = time.Time{}
|
|
|
|
m.EXPECT().GetAssets(gomock.Any(), "entity-err").Return(nil, errors.New("boom"))
|
|
|
|
err := plg.ensureAssetsFresh(ctx)
|
|
Expect(err).To(MatchError(ContainSubstring("boom")))
|
|
// Stale timestamp is preserved so next call will retry.
|
|
Expect(plg.assetsLastSync).To(BeZero())
|
|
})
|
|
})
|
|
|
|
var _ = Describe("Coinbase Plugin resolveAssetAndPrecision", func() {
|
|
var (
|
|
ctrl *gomock.Controller
|
|
m *client.MockClient
|
|
plg *Plugin
|
|
)
|
|
|
|
BeforeEach(func() {
|
|
ctrl = gomock.NewController(GinkgoT())
|
|
m = client.NewMockClient(ctrl)
|
|
plg = &Plugin{
|
|
Plugin: plugins.NewBasePlugin(),
|
|
client: m,
|
|
logger: logging.NewDefaultLogger(GinkgoWriter, true, false, false),
|
|
// Pre-populated, TTL-fresh cache. No client mocks below — any
|
|
// unexpected client call fails the test, proving the fast-path
|
|
// served these cases.
|
|
currencies: map[string]int{
|
|
"BTC": 8,
|
|
"USDC": 6,
|
|
"USD": 2,
|
|
},
|
|
networkSymbols: map[string]string{
|
|
"BASEUSDC": "USDC",
|
|
},
|
|
assetsLastSync: time.Now(),
|
|
}
|
|
})
|
|
|
|
AfterEach(func() {
|
|
ctrl.Finish()
|
|
})
|
|
|
|
It("resolves a known symbol to asset and precision", func(ctx SpecContext) {
|
|
asset, precision, ok, err := plg.resolveAssetAndPrecision(ctx, "BTC")
|
|
Expect(err).To(BeNil())
|
|
Expect(ok).To(BeTrue())
|
|
Expect(asset).To(Equal("BTC/8"))
|
|
Expect(precision).To(Equal(8))
|
|
})
|
|
|
|
It("folds network-scoped symbols to the base symbol", func(ctx SpecContext) {
|
|
asset, precision, ok, err := plg.resolveAssetAndPrecision(ctx, "BASEUSDC")
|
|
Expect(err).To(BeNil())
|
|
Expect(ok).To(BeTrue())
|
|
Expect(asset).To(Equal("USDC/6"))
|
|
Expect(precision).To(Equal(6))
|
|
})
|
|
|
|
It("normalizes whitespace and case before lookup", func(ctx SpecContext) {
|
|
asset, precision, ok, err := plg.resolveAssetAndPrecision(ctx, " btc ")
|
|
Expect(err).To(BeNil())
|
|
Expect(ok).To(BeTrue())
|
|
Expect(asset).To(Equal("BTC/8"))
|
|
Expect(precision).To(Equal(8))
|
|
})
|
|
|
|
It("returns ok=false with no error for an unknown symbol", func(ctx SpecContext) {
|
|
asset, precision, ok, err := plg.resolveAssetAndPrecision(ctx, "DOGE")
|
|
Expect(err).To(BeNil())
|
|
Expect(ok).To(BeFalse())
|
|
Expect(asset).To(BeEmpty())
|
|
Expect(precision).To(Equal(0))
|
|
})
|
|
|
|
It("propagates freshness-refresh errors from getAssets", func(ctx SpecContext) {
|
|
// Force a refresh attempt by making the cache stale; mock GetAssets to fail.
|
|
plg.assetsLastSync = time.Time{}
|
|
plg.entityID = "entity-resolve-err"
|
|
m.EXPECT().GetAssets(gomock.Any(), "entity-resolve-err").Return(nil, errors.New("boom"))
|
|
|
|
asset, precision, ok, err := plg.resolveAssetAndPrecision(ctx, "BTC")
|
|
Expect(err).To(MatchError(ContainSubstring("boom")))
|
|
Expect(ok).To(BeFalse())
|
|
Expect(asset).To(BeEmpty())
|
|
Expect(precision).To(Equal(0))
|
|
})
|
|
})
|