mirror of
https://github.com/d0zingcat/gocryptotrader.git
synced 2026-06-09 07:26:48 +00:00
subscriptions: Encapsulate, replace Pair with Pairs and refactor; improve exchange support
* Websocket: Use ErrSubscribedAlready instead of errChannelAlreadySubscribed * Subscriptions: Replace Pair with Pairs Given that some subscriptions have multiple pairs, support that as the standard. * Docs: Update subscriptions in add new exch * RPC: Update Subscription Pairs * Linter: Disable testifylint.Len We deliberately use Equal over Len to avoid spamming the contents of large Slices * Websocket: Add suffix to state consts * Binance: Subscription Pairs support * Bitfinex: Subscription Pairs support * Bithumb: Subscription Pairs support * Bitmex: Subscription Pairs support * Bitstamp: Subscription Pairs support * BTCMarkets: Subscription Pairs support * BTSE: Subscription Pairs support * Coinbase: Subscription Pairs support * Coinut: Subscription Pairs support * GateIO: Subscription Pairs support * Gemini: Subscription Pairs support and improvement * Hitbtc: Subscription Pairs support * Huboi: Subscription Pairs support * Kucoin: Subscription Pairs support * Okcoin: Subscription Pairs support * Poloniex: Subscription Pairs support * Kraken: Add subscription Pairs support Note: This is a naieve implementation because we want to rebase the kraken websocket rewrite on top of this * Bybit: Subscription Pairs support * Okx: Subscription Pairs support * Bitmex: Subsription configuration * Fixes unauthenticated websocket left as CanUseAuth * Fixes auth subs happening privately * CoinbasePro: Subscription Configuration * Consolidate ProductIDs when all subscriptions are for the same list * Websocket: Log actual sent message when Verbose * Subscriptions: Improve clarity of which key is which in Match * Subscriptions: Lint fix for HugeParam * Subscriptions: Add AddPairs and move keys from test * Subscriptions: Simplify subscription keys and add key types * Subscriptions: Add List.GroupPairs Rename sub.AddPairs * Subscription: Fix ExactKey not matching 0 pairs * Subscriptions: Remove unused IdentityKey and HasPairKey * Subscriptions: Fix GetKey test * Subscriptions: Test coverage improvements * Websocket: Change State on Add/Remove * Subscriptions: Improve error context * Subscriptions: Fix Enable: false subs not ignored * Bitfinex: Fix WsAuth test failing on DataHandler DataHandler is eaten by dataMonitor now, so we need to use ToRoutine * Deribit: Subscription Pairs support * Websocket: Accept nil lists for checkSubscriptions If the user passes in a nil (implicitly empty) list, we would not panic. Therefore the burden of correctness about that data lies with them. The list of subscriptions is empty, and that's okay, and possibly convenient * Websocket: Add context to NilPointer errors * Subscriptions: Add context to nil errors * Exchange: Fix error expectations in UnsubToWSChans
This commit is contained in:
88
exchanges/subscription/keys.go
Normal file
88
exchanges/subscription/keys.go
Normal file
@@ -0,0 +1,88 @@
|
||||
package subscription
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"github.com/thrasher-corp/gocryptotrader/currency"
|
||||
)
|
||||
|
||||
// MatchableKey interface should be implemented by Key types which want a more complex matching than a simple key equality check
|
||||
// The Subscription method allows keys to compare against keys of other types
|
||||
type MatchableKey interface {
|
||||
Match(MatchableKey) bool
|
||||
GetSubscription() *Subscription
|
||||
String() string
|
||||
}
|
||||
|
||||
// ExactKey is key type for subscriptions where all the pairs in a Subscription must match exactly
|
||||
type ExactKey struct {
|
||||
*Subscription
|
||||
}
|
||||
|
||||
var _ MatchableKey = ExactKey{} // Enforce ExactKey must implement MatchableKey
|
||||
|
||||
// GetSubscription returns the underlying subscription
|
||||
func (k ExactKey) GetSubscription() *Subscription {
|
||||
return k.Subscription
|
||||
}
|
||||
|
||||
// String implements Stringer; returns the Asset, Channel and Pairs
|
||||
// Does not provide concurrency protection on the subscription it points to
|
||||
func (k ExactKey) String() string {
|
||||
s := k.Subscription
|
||||
if s == nil {
|
||||
return "Uninitialised ExactKey"
|
||||
}
|
||||
p := s.Pairs.Format(currency.PairFormat{Uppercase: true, Delimiter: "/"})
|
||||
return fmt.Sprintf("%s %s %s", s.Channel, s.Asset, p.Join())
|
||||
}
|
||||
|
||||
// Match implements MatchableKey
|
||||
// Returns true if the key fields exactly matches the subscription, including all Pairs
|
||||
func (k ExactKey) Match(eachKey MatchableKey) bool {
|
||||
if eachKey == nil {
|
||||
return false
|
||||
}
|
||||
eachSub := eachKey.GetSubscription()
|
||||
return eachSub != nil &&
|
||||
eachSub.Channel == k.Channel &&
|
||||
eachSub.Asset == k.Asset &&
|
||||
eachSub.Pairs.Equal(k.Pairs) &&
|
||||
eachSub.Levels == k.Levels &&
|
||||
eachSub.Interval == k.Interval
|
||||
}
|
||||
|
||||
// IgnoringPairsKey is a key type for finding subscriptions to group together for requests
|
||||
type IgnoringPairsKey struct {
|
||||
*Subscription
|
||||
}
|
||||
|
||||
var _ MatchableKey = IgnoringPairsKey{} // Enforce IgnoringPairsKey must implement MatchableKey
|
||||
|
||||
// GetSubscription returns the underlying subscription
|
||||
func (k IgnoringPairsKey) GetSubscription() *Subscription {
|
||||
return k.Subscription
|
||||
}
|
||||
|
||||
// String implements Stringer; returns the asset and Channel name but no pairs
|
||||
func (k IgnoringPairsKey) String() string {
|
||||
s := k.Subscription
|
||||
if s == nil {
|
||||
return "Uninitialised IgnoringPairsKey"
|
||||
}
|
||||
return fmt.Sprintf("%s %s", s.Channel, s.Asset)
|
||||
}
|
||||
|
||||
// Match implements MatchableKey
|
||||
func (k IgnoringPairsKey) Match(eachKey MatchableKey) bool {
|
||||
if eachKey == nil {
|
||||
return false
|
||||
}
|
||||
eachSub := eachKey.GetSubscription()
|
||||
|
||||
return eachSub != nil &&
|
||||
eachSub.Channel == k.Channel &&
|
||||
eachSub.Asset == k.Asset &&
|
||||
eachSub.Levels == k.Levels &&
|
||||
eachSub.Interval == k.Interval
|
||||
}
|
||||
110
exchanges/subscription/keys_test.go
Normal file
110
exchanges/subscription/keys_test.go
Normal file
@@ -0,0 +1,110 @@
|
||||
package subscription
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
"github.com/thrasher-corp/gocryptotrader/currency"
|
||||
"github.com/thrasher-corp/gocryptotrader/exchanges/asset"
|
||||
"github.com/thrasher-corp/gocryptotrader/exchanges/kline"
|
||||
)
|
||||
|
||||
// DummyKey is a test key type that ensures that cross compatible keys can be used
|
||||
// It will panic if Match() is called
|
||||
type DummyKey struct {
|
||||
*Subscription
|
||||
detonator testing.TB
|
||||
}
|
||||
|
||||
var _ MatchableKey = DummyKey{} // Enforce DummyKey must implement MatchableKey
|
||||
|
||||
// GetSubscription returns the underlying subscription
|
||||
func (k DummyKey) GetSubscription() *Subscription {
|
||||
return k.Subscription
|
||||
}
|
||||
|
||||
// Match implements MatchableKey
|
||||
func (k DummyKey) Match(_ MatchableKey) bool {
|
||||
k.detonator.Fatal("DummyKey Match should never be called")
|
||||
return false
|
||||
}
|
||||
|
||||
// TestExactKeyMatch exercises ExactKey.Match
|
||||
func TestExactKeyMatch(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
key := &ExactKey{&Subscription{Channel: TickerChannel}}
|
||||
try := &DummyKey{&Subscription{Channel: OrderbookChannel}, t}
|
||||
|
||||
require.False(t, key.Match(nil), "Match on a nil must return false")
|
||||
require.False(t, key.Match(try), "Gate 1: Match must reject a bad Channel")
|
||||
try.Channel = TickerChannel
|
||||
require.True(t, key.Match(try), "Gate 1: Match must accept a good Channel")
|
||||
key.Asset = asset.Spot
|
||||
require.False(t, key.Match(try), "Gate 2: Match must reject a bad Asset")
|
||||
try.Asset = asset.Spot
|
||||
require.True(t, key.Match(try), "Gate 2: Match must accept a good Asset")
|
||||
key.Pairs = currency.Pairs{btcusdtPair}
|
||||
require.False(t, key.Match(try), "Gate 3: Match must reject B empty Pairs when key has Pairs")
|
||||
try.Pairs = currency.Pairs{btcusdtPair}
|
||||
key.Pairs = nil
|
||||
require.False(t, key.Match(try), "Gate 3: Match must reject B has Pairs when key has empty Pairs")
|
||||
key.Pairs = currency.Pairs{btcusdtPair}
|
||||
require.True(t, key.Match(try), "Gate 3: Match must accept matching pairs")
|
||||
key.Pairs = currency.Pairs{ethusdcPair}
|
||||
require.False(t, key.Match(try), "Gate 3: Match must reject when key.Pairs not matching")
|
||||
try.Pairs = currency.Pairs{btcusdtPair, ethusdcPair}
|
||||
require.False(t, key.Match(try), "Gate 3: Match must reject when key.Pairs is only a subset")
|
||||
key.Pairs = currency.Pairs{ethusdcPair, btcusdtPair}
|
||||
require.True(t, key.Match(try), "Gate 3: Match accept when Pairs match in different order")
|
||||
key.Levels = 4
|
||||
require.False(t, key.Match(try), "Gate 4: Match must reject a bad Level")
|
||||
try.Levels = 4
|
||||
require.True(t, key.Match(try), "Gate 4: Match must accept a good Level")
|
||||
key.Interval = kline.FiveMin
|
||||
require.False(t, key.Match(try), "Gate 5: Match must reject a bad Interval")
|
||||
try.Interval = kline.FiveMin
|
||||
require.True(t, key.Match(try), "Gate 5: Match must accept a good Interval")
|
||||
}
|
||||
|
||||
// TestExactKeyString exercises ExactKey.String
|
||||
func TestExactKeyString(t *testing.T) {
|
||||
t.Parallel()
|
||||
key := &ExactKey{}
|
||||
assert.Equal(t, "Uninitialised ExactKey", key.String())
|
||||
key = &ExactKey{&Subscription{Asset: asset.Spot, Channel: TickerChannel, Pairs: currency.Pairs{ethusdcPair, btcusdtPair}}}
|
||||
assert.Equal(t, "ticker spot ETH/USDC,BTC/USDT", key.String())
|
||||
}
|
||||
|
||||
// TestIgnoringPairsKeyMatch exercises IgnoringPairsKey.Match
|
||||
func TestIgnoringPairsKeyMatch(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
key := &IgnoringPairsKey{&Subscription{Channel: TickerChannel, Pairs: currency.Pairs{btcusdtPair}}}
|
||||
try := &DummyKey{&Subscription{Channel: OrderbookChannel, Pairs: currency.Pairs{ethusdcPair}}, t}
|
||||
|
||||
require.False(t, key.Match(nil), "Match on a nil must return false")
|
||||
require.False(t, key.Match(try), "Gate 1: Match must reject a bad Channel")
|
||||
try.Channel = TickerChannel
|
||||
require.True(t, key.Match(try), "Gate 1: Match must accept a good Channel")
|
||||
key.Asset = asset.Spot
|
||||
require.False(t, key.Match(try), "Gate 2: Match must reject a bad Asset")
|
||||
try.Asset = asset.Spot
|
||||
require.True(t, key.Match(try), "Gate 2: Match must accept a good Asset")
|
||||
key.Levels = 4
|
||||
require.False(t, key.Match(try), "Gate 3: Match must reject a bad Level")
|
||||
try.Levels = 4
|
||||
require.True(t, key.Match(try), "Gate 3: Match must accept a good Level")
|
||||
key.Interval = kline.FiveMin
|
||||
require.False(t, key.Match(try), "Gate 4: Match must reject a bad Interval")
|
||||
try.Interval = kline.FiveMin
|
||||
require.True(t, key.Match(try), "Gate 4: Match must accept a good Interval")
|
||||
}
|
||||
|
||||
// TestIgnoringPairsKeyString exercises IgnoringPairsKey.String
|
||||
func TestIgnoringPairsKeyString(t *testing.T) {
|
||||
t.Parallel()
|
||||
key := &IgnoringPairsKey{&Subscription{Asset: asset.Spot, Channel: TickerChannel, Pairs: currency.Pairs{ethusdcPair, btcusdtPair}}}
|
||||
assert.Equal(t, "ticker spot", key.String())
|
||||
}
|
||||
32
exchanges/subscription/list.go
Normal file
32
exchanges/subscription/list.go
Normal file
@@ -0,0 +1,32 @@
|
||||
package subscription
|
||||
|
||||
import (
|
||||
"slices"
|
||||
)
|
||||
|
||||
// List is a container of subscription pointers
|
||||
type List []*Subscription
|
||||
|
||||
// Strings returns a sorted slice of subscriptions
|
||||
func (l List) Strings() []string {
|
||||
s := make([]string, len(l))
|
||||
for i := range l {
|
||||
s[i] = l[i].String()
|
||||
}
|
||||
slices.Sort(s)
|
||||
return s
|
||||
}
|
||||
|
||||
// GroupPairs groups subscriptions which are identical apart from the Pairs
|
||||
// The returned List contains cloned Subscriptions, and the original Subscriptions are left alone
|
||||
func (l List) GroupPairs() (n List) {
|
||||
s := NewStore()
|
||||
for _, sub := range l {
|
||||
if found := s.match(&IgnoringPairsKey{sub}); found == nil {
|
||||
s.unsafeAdd(sub.Clone())
|
||||
} else {
|
||||
found.AddPairs(sub.Pairs...)
|
||||
}
|
||||
}
|
||||
return s.List()
|
||||
}
|
||||
47
exchanges/subscription/list_test.go
Normal file
47
exchanges/subscription/list_test.go
Normal file
@@ -0,0 +1,47 @@
|
||||
package subscription
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/thrasher-corp/gocryptotrader/currency"
|
||||
"github.com/thrasher-corp/gocryptotrader/exchanges/asset"
|
||||
)
|
||||
|
||||
// TestListStrings exercises List.Strings()
|
||||
func TestListStrings(t *testing.T) {
|
||||
l := List{
|
||||
&Subscription{
|
||||
Channel: TickerChannel,
|
||||
Asset: asset.Spot,
|
||||
Pairs: currency.Pairs{ethusdcPair, btcusdtPair},
|
||||
},
|
||||
&Subscription{
|
||||
Channel: OrderbookChannel,
|
||||
Pairs: currency.Pairs{ethusdcPair},
|
||||
},
|
||||
}
|
||||
exp := []string{"orderbook ETH/USDC", "ticker spot ETH/USDC,BTC/USDT"}
|
||||
assert.ElementsMatch(t, exp, l.Strings(), "String must return correct sorted list")
|
||||
}
|
||||
|
||||
// TestListGroupPairs exercises List.GroupPairs()
|
||||
func TestListGroupPairs(t *testing.T) {
|
||||
l := List{
|
||||
{Asset: asset.Spot, Channel: TickerChannel, Pairs: currency.Pairs{ethusdcPair, btcusdtPair}},
|
||||
}
|
||||
for _, c := range []string{TickerChannel, OrderbookChannel} {
|
||||
for _, p := range []currency.Pair{ethusdcPair, btcusdtPair} {
|
||||
l = append(l, &Subscription{
|
||||
Channel: c,
|
||||
Asset: asset.Spot,
|
||||
Pairs: currency.Pairs{p},
|
||||
})
|
||||
}
|
||||
}
|
||||
n := l.GroupPairs()
|
||||
assert.Len(t, l, 5, "Orig list should not be changed")
|
||||
assert.Len(t, n, 2, "New list should be grouped")
|
||||
exp := []string{"ticker spot ETH/USDC,BTC/USDT", "orderbook spot ETH/USDC,BTC/USDT"}
|
||||
assert.ElementsMatch(t, exp, n.Strings(), "String must return correct sorted list")
|
||||
}
|
||||
208
exchanges/subscription/store.go
Normal file
208
exchanges/subscription/store.go
Normal file
@@ -0,0 +1,208 @@
|
||||
package subscription
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"maps"
|
||||
"sync"
|
||||
|
||||
"github.com/thrasher-corp/gocryptotrader/common"
|
||||
)
|
||||
|
||||
// Store is a container of subscription pointers
|
||||
type Store struct {
|
||||
m map[any]*Subscription
|
||||
mu sync.RWMutex
|
||||
}
|
||||
|
||||
// NewStore creates a ready to use store and should always be used
|
||||
func NewStore() *Store {
|
||||
return &Store{
|
||||
m: map[any]*Subscription{},
|
||||
}
|
||||
}
|
||||
|
||||
// NewStoreFromList creates a Store from a List
|
||||
func NewStoreFromList(l List) (*Store, error) {
|
||||
s := NewStore()
|
||||
for _, sub := range l {
|
||||
if sub == nil {
|
||||
return nil, fmt.Errorf("%w: List parameter contains an nil element", common.ErrNilPointer)
|
||||
}
|
||||
if err := s.add(sub); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
return s, nil
|
||||
}
|
||||
|
||||
// Add adds a subscription to the store
|
||||
// Key can be already set; if omitted EnsureKeyed will be used
|
||||
// Errors if it already exists
|
||||
func (s *Store) Add(sub *Subscription) error {
|
||||
if s == nil {
|
||||
return fmt.Errorf("%w: Add called on nil Store", common.ErrNilPointer)
|
||||
}
|
||||
if s.m == nil {
|
||||
return fmt.Errorf("%w: Add called on an Uninitialised Store", common.ErrNilPointer)
|
||||
}
|
||||
if sub == nil {
|
||||
return fmt.Errorf("%w: Subscription param", common.ErrNilPointer)
|
||||
}
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
return s.add(sub)
|
||||
}
|
||||
|
||||
// add adds a subscription to the store
|
||||
// Key can be already set; if omitted EnsureKeyed will be used
|
||||
// This method provides no locking protection
|
||||
func (s *Store) add(sub *Subscription) error {
|
||||
key := sub.EnsureKeyed()
|
||||
if found := s.get(key); found != nil {
|
||||
return fmt.Errorf("%w: %s", ErrDuplicate, sub)
|
||||
}
|
||||
s.m[key] = sub
|
||||
return nil
|
||||
}
|
||||
|
||||
// unsafeAdd adds a subscription to the store without checking if it is a duplicate
|
||||
// Key can be already set; if omitted EnsureKeyed will be used
|
||||
// This method provides no locking protection
|
||||
func (s *Store) unsafeAdd(sub *Subscription) {
|
||||
key := sub.EnsureKeyed()
|
||||
s.m[key] = sub
|
||||
}
|
||||
|
||||
// Get returns a pointer to a subscription or nil if not found
|
||||
// If the key passed in is a Subscription then its Key will be used; which may be a pointer to itself.
|
||||
// If key implements MatchableKey then key.Match will be used; Note that *Subscription implements MatchableKey
|
||||
func (s *Store) Get(key any) *Subscription {
|
||||
if s == nil || s.m == nil || key == nil {
|
||||
return nil
|
||||
}
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
return s.get(key)
|
||||
}
|
||||
|
||||
// get returns a pointer to subscription or nil if not found
|
||||
// If the key passed in is a Subscription then its Key will be used; which may be a pointer to itself.
|
||||
// If key implements MatchableKey then key.Match will be used; Note that *Subscription implements MatchableKey
|
||||
// This method provides no locking protection
|
||||
func (s *Store) get(key any) *Subscription {
|
||||
switch v := key.(type) {
|
||||
case Subscription:
|
||||
key = v.EnsureKeyed()
|
||||
case *Subscription:
|
||||
key = v.EnsureKeyed()
|
||||
}
|
||||
|
||||
switch v := key.(type) {
|
||||
case MatchableKey:
|
||||
return s.match(v)
|
||||
default:
|
||||
return s.m[v]
|
||||
}
|
||||
}
|
||||
|
||||
// Remove removes a subscription from the store
|
||||
// If the key passed in is a Subscription then its Key will be used; which may be a pointer to itself.
|
||||
// If key implements MatchableKey then key.Match will be used; Note that *Subscription implements MatchableKey
|
||||
func (s *Store) Remove(key any) error {
|
||||
if s == nil {
|
||||
return fmt.Errorf("%w: Remove called on nil Store", common.ErrNilPointer)
|
||||
}
|
||||
if s.m == nil {
|
||||
return fmt.Errorf("%w: Remove called on an Uninitialised Store", common.ErrNilPointer)
|
||||
}
|
||||
if key == nil {
|
||||
return fmt.Errorf("%w: key param", common.ErrNilPointer)
|
||||
}
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
|
||||
if found := s.get(key); found != nil {
|
||||
delete(s.m, found.Key)
|
||||
return nil
|
||||
}
|
||||
|
||||
return ErrNotFound
|
||||
}
|
||||
|
||||
// List returns a slice of Subscriptions pointers
|
||||
func (s *Store) List() List {
|
||||
if s == nil || s.m == nil {
|
||||
return List{}
|
||||
}
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
subs := make(List, 0, len(s.m))
|
||||
for _, sub := range s.m {
|
||||
subs = append(subs, sub)
|
||||
}
|
||||
return subs
|
||||
}
|
||||
|
||||
// Clear empties the subscription store
|
||||
func (s *Store) Clear() {
|
||||
if s == nil {
|
||||
return
|
||||
}
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
if s.m == nil {
|
||||
s.m = map[any]*Subscription{}
|
||||
}
|
||||
clear(s.m)
|
||||
}
|
||||
|
||||
// match returns the first subscription which matches the Key's Asset, Channel and Pairs
|
||||
// If the key provided has:
|
||||
// 1) Empty pairs then only Subscriptions without pairs will be considered
|
||||
// 2) >=1 pairs then Subscriptions which contain all the pairs will be considered
|
||||
// This method provides no locking protection
|
||||
func (s *Store) match(key MatchableKey) *Subscription {
|
||||
for eachKey, sub := range s.m {
|
||||
if m, ok := eachKey.(MatchableKey); ok {
|
||||
if key.Match(m) {
|
||||
return sub
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Diff returns a list of the added and missing subs from a new list
|
||||
// The store Diff is invoked upon is read-lock protected
|
||||
// The new store is assumed to be a new instance and enjoys no locking protection
|
||||
func (s *Store) Diff(compare List) (added, removed List) {
|
||||
if s == nil || s.m == nil {
|
||||
return
|
||||
}
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
removedMap := maps.Clone(s.m)
|
||||
for _, sub := range compare {
|
||||
if found := s.get(sub); found != nil {
|
||||
delete(removedMap, found.Key)
|
||||
} else {
|
||||
added = append(added, sub)
|
||||
}
|
||||
}
|
||||
|
||||
for _, c := range removedMap {
|
||||
removed = append(removed, c)
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
// Len returns the number of subscriptions
|
||||
func (s *Store) Len() int {
|
||||
if s == nil {
|
||||
return 0
|
||||
}
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
return len(s.m)
|
||||
}
|
||||
182
exchanges/subscription/store_test.go
Normal file
182
exchanges/subscription/store_test.go
Normal file
@@ -0,0 +1,182 @@
|
||||
package subscription
|
||||
|
||||
import (
|
||||
"maps"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
"github.com/thrasher-corp/gocryptotrader/common"
|
||||
"github.com/thrasher-corp/gocryptotrader/currency"
|
||||
)
|
||||
|
||||
// TestNewStore exercises NewStore
|
||||
func TestNewStore(t *testing.T) {
|
||||
s := NewStore()
|
||||
require.IsType(t, &Store{}, s, "Must return a store ref")
|
||||
require.NotNil(t, s.m, "storage map must be initialised")
|
||||
}
|
||||
|
||||
// TestNewStoreFromList exercises NewStoreFromList
|
||||
func TestNewStoreFromList(t *testing.T) {
|
||||
s, err := NewStoreFromList(List{})
|
||||
assert.NoError(t, err, "Should not error on empty list")
|
||||
require.IsType(t, &Store{}, s, "Must return a store ref")
|
||||
l := List{
|
||||
{Channel: OrderbookChannel},
|
||||
{Channel: TickerChannel},
|
||||
}
|
||||
s, err = NewStoreFromList(l)
|
||||
assert.NoError(t, err, "Should not error on empty list")
|
||||
assert.Len(t, s.m, 2, "Map should have 2 values")
|
||||
assert.NotNil(t, s.get(l[0]), "Should be able to get a list element")
|
||||
|
||||
l = append(l, &Subscription{Channel: OrderbookChannel})
|
||||
_, err = NewStoreFromList(l)
|
||||
assert.ErrorIs(t, err, ErrDuplicate, "Should error correctly on duplicates")
|
||||
|
||||
l = List{nil, &Subscription{Channel: OrderbookChannel}}
|
||||
_, err = NewStoreFromList(l)
|
||||
assert.ErrorIs(t, err, common.ErrNilPointer, "Should error correctly on nils")
|
||||
}
|
||||
|
||||
// TestAdd exercises Add and add methods
|
||||
func TestAdd(t *testing.T) {
|
||||
assert.ErrorIs(t, (*Store)(nil).Add(&Subscription{}), common.ErrNilPointer, "Should error nil pointer correctly")
|
||||
assert.ErrorIs(t, (&Store{}).Add(nil), common.ErrNilPointer, "Should error nil pointer correctly")
|
||||
assert.ErrorIs(t, (&Store{}).Add(&Subscription{}), common.ErrNilPointer, "Should error nil pointer correctly")
|
||||
|
||||
s := NewStore()
|
||||
sub := &Subscription{Channel: TickerChannel}
|
||||
require.NoError(t, s.Add(sub), "Should not error on a standard add")
|
||||
assert.NotNil(t, s.get(sub), "Should have stored the sub")
|
||||
assert.ErrorIs(t, s.Add(sub), ErrDuplicate, "Should error on duplicates")
|
||||
assert.NotNil(t, sub.Key, sub, "Add should call EnsureKeyed")
|
||||
}
|
||||
|
||||
// TestGet exercises Get and get methods
|
||||
// Ensures that key's Match is used, but does not exercise subscription.Match; See TestMatch for that coverage
|
||||
func TestGet(t *testing.T) {
|
||||
assert.Nil(t, (*Store)(nil).Get(&Subscription{}), "Should return nil when called on nil")
|
||||
assert.Nil(t, (&Store{}).Get(&Subscription{}), "Should return nil when called with no subscription map")
|
||||
s := NewStore()
|
||||
exp := List{
|
||||
{Channel: AllOrdersChannel},
|
||||
{Channel: TickerChannel, Pairs: currency.Pairs{btcusdtPair}},
|
||||
{Key: 42, Channel: OrderbookChannel},
|
||||
{Channel: CandlesChannel, Pairs: currency.Pairs{btcusdtPair, ethusdcPair}},
|
||||
}
|
||||
for _, sub := range exp {
|
||||
require.NoError(t, s.Add(sub), "Adding subscription must not error)")
|
||||
}
|
||||
|
||||
// Tests for a MatchableKey, ensuring that ExactKey works
|
||||
assert.Nil(t, s.Get(Subscription{Channel: CandlesChannel}), "Should return nil without pairs")
|
||||
assert.Nil(t, s.Get(Subscription{Channel: CandlesChannel, Pairs: currency.Pairs{ltcusdcPair}}), "Should return nil with wrong pair")
|
||||
assert.Nil(t, s.Get(Subscription{Channel: CandlesChannel, Pairs: currency.Pairs{btcusdtPair}}), "Should return nil with only one right pair")
|
||||
assert.Same(t, exp[3], s.Get(Subscription{Channel: CandlesChannel, Pairs: currency.Pairs{btcusdtPair, ethusdcPair}}), "Should return pointer when all pairs match")
|
||||
assert.Nil(t, s.Get(Subscription{Channel: CandlesChannel, Pairs: currency.Pairs{btcusdtPair, ethusdcPair, ltcusdcPair}}), "Should return nil when key is superset of pairs")
|
||||
}
|
||||
|
||||
// TestRemove exercises the Remove method
|
||||
func TestRemove(t *testing.T) {
|
||||
assert.ErrorIs(t, (*Store)(nil).Remove(&Subscription{}), common.ErrNilPointer, "Should error correctly when called on nil")
|
||||
assert.ErrorIs(t, (&Store{}).Remove(nil), common.ErrNilPointer, "Should error correctly when called passing nil")
|
||||
assert.ErrorIs(t, (&Store{}).Remove(&Subscription{}), common.ErrNilPointer, "Should error correctly when called with no subscription map")
|
||||
|
||||
s := NewStore()
|
||||
require.NoError(t, s.Add(&Subscription{Channel: CandlesChannel, Pairs: currency.Pairs{btcusdtPair, ethusdcPair}}), "Adding subscription must not error")
|
||||
assert.NotNil(t, s.Get(&ExactKey{&Subscription{Channel: CandlesChannel, Pairs: currency.Pairs{btcusdtPair, ethusdcPair}}}), "Should have added the sub")
|
||||
assert.ErrorIs(t, s.Remove(&ExactKey{&Subscription{Channel: CandlesChannel, Pairs: currency.Pairs{btcusdtPair}}}), ErrNotFound, "Should error correctly when called with a non-matching key")
|
||||
assert.NoError(t, s.Remove(&ExactKey{&Subscription{Channel: CandlesChannel, Pairs: currency.Pairs{btcusdtPair, ethusdcPair}}}), "Should not error when called with a matching key")
|
||||
assert.Nil(t, s.Get(&ExactKey{&Subscription{Channel: CandlesChannel, Pairs: currency.Pairs{btcusdtPair, ethusdcPair}}}), "Should have removed the sub")
|
||||
assert.ErrorIs(t, s.Remove(&ExactKey{&Subscription{Channel: CandlesChannel, Pairs: currency.Pairs{btcusdtPair, ethusdcPair}}}), ErrNotFound, "Should error correctly when called twice ")
|
||||
}
|
||||
|
||||
// TestList exercises the List and Len methods
|
||||
func TestList(t *testing.T) {
|
||||
assert.Empty(t, (*Store)(nil).List(), "Should return an empty List when called on nil")
|
||||
assert.Empty(t, (&Store{}).List(), "Should return an empty List when called on Store without map")
|
||||
s := NewStore()
|
||||
exp := List{
|
||||
{Channel: OrderbookChannel},
|
||||
{Channel: TickerChannel},
|
||||
{Key: 42, Channel: CandlesChannel},
|
||||
}
|
||||
for _, sub := range exp {
|
||||
require.NoError(t, s.Add(sub), "Adding subscription must not error)")
|
||||
}
|
||||
l := s.List()
|
||||
require.Len(t, l, 3, "Must have 3 elements in the list")
|
||||
assert.ElementsMatch(t, exp, l, "List Should have the same subscriptions")
|
||||
|
||||
require.Equal(t, 3, s.Len(), "Len must return 3")
|
||||
require.Equal(t, 0, (*Store)(nil).Len(), "Len must return 0 on a nil store")
|
||||
require.Equal(t, 0, (&Store{}).Len(), "Len must return 0 on an uninitialized store")
|
||||
}
|
||||
|
||||
// TestStoreClear exercises the Clear method
|
||||
func TestStoreClear(t *testing.T) {
|
||||
assert.NotPanics(t, func() { (*Store)(nil).Clear() }, "Should not panic when called on nil")
|
||||
s := &Store{}
|
||||
assert.NotPanics(t, func() { s.Clear() }, "Should not panic when called with no subscription map")
|
||||
assert.NotNil(t, s.m, "Should create a map when called on an empty Store")
|
||||
require.NoError(t, s.Add(&Subscription{Channel: CandlesChannel}), "Adding subscription must not error")
|
||||
require.Len(t, s.m, 1, "Must have a subscription")
|
||||
s.Clear()
|
||||
require.Empty(t, s.m, "Map must be empty after clearing")
|
||||
assert.NotPanics(t, func() { s.Clear() }, "Should not panic when called on an empty map")
|
||||
}
|
||||
|
||||
// TestStoreDiff exercises the Diff method
|
||||
func TestStoreDiff(t *testing.T) {
|
||||
s := NewStore()
|
||||
assert.NotPanics(t, func() { (*Store)(nil).Diff(List{}) }, "Should not panic when called on nil")
|
||||
assert.NotPanics(t, func() { (&Store{}).Diff(List{}) }, "Should not panic when called with no subscription map")
|
||||
subs, unsubs := s.Diff(List{{Channel: TickerChannel}, {Channel: CandlesChannel}, {Channel: OrderbookChannel}})
|
||||
assert.Equal(t, 3, len(subs), "Should get the correct number of subs")
|
||||
assert.Empty(t, unsubs, "Should get no unsubs")
|
||||
for _, sub := range subs {
|
||||
require.NoError(t, s.add(sub), "add must not error")
|
||||
}
|
||||
assert.NotPanics(t, func() { s.Diff(nil) }, "Should not panic when called with nil list")
|
||||
|
||||
subs, unsubs = s.Diff(List{{Channel: CandlesChannel}})
|
||||
assert.Empty(t, subs, "Should get no subs")
|
||||
assert.Equal(t, 2, len(unsubs), "Should get the correct number of unsubs")
|
||||
subs, unsubs = s.Diff(List{{Channel: TickerChannel}, {Channel: MyTradesChannel}})
|
||||
require.Equal(t, 1, len(subs), "Should get the correct number of subs")
|
||||
assert.Equal(t, MyTradesChannel, subs[0].Channel, "Should get correct channels in sub")
|
||||
require.Equal(t, 2, len(unsubs), "Should get the correct number of unsubs")
|
||||
EqualLists(t, unsubs, List{{Channel: OrderbookChannel}, {Channel: CandlesChannel}})
|
||||
}
|
||||
|
||||
func EqualLists(tb testing.TB, a, b List) {
|
||||
tb.Helper()
|
||||
// Must not use store.Diff directly
|
||||
s, err := NewStoreFromList(a)
|
||||
require.NoError(tb, err, "NewStoreFromList must not error")
|
||||
missingMap := maps.Clone(s.m)
|
||||
var added, missing List
|
||||
for _, sub := range b {
|
||||
if found := s.get(sub); found != nil {
|
||||
delete(missingMap, found.Key)
|
||||
} else {
|
||||
added = append(added, sub)
|
||||
}
|
||||
}
|
||||
for _, c := range missingMap {
|
||||
missing = append(missing, c)
|
||||
}
|
||||
if len(added) > 0 || len(missing) > 0 {
|
||||
fail := "Differences:"
|
||||
if len(added) > 0 {
|
||||
fail = fail + "\n + " + strings.Join(added.Strings(), "\n + ")
|
||||
}
|
||||
if len(missing) > 0 {
|
||||
fail = fail + "\n - " + strings.Join(missing.Strings(), "\n - ")
|
||||
}
|
||||
assert.Fail(tb, fail, "Subscriptions should be equal")
|
||||
}
|
||||
}
|
||||
@@ -1,92 +1,163 @@
|
||||
package subscription
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"maps"
|
||||
"slices"
|
||||
"sync"
|
||||
|
||||
"github.com/thrasher-corp/gocryptotrader/currency"
|
||||
"github.com/thrasher-corp/gocryptotrader/exchanges/asset"
|
||||
"github.com/thrasher-corp/gocryptotrader/exchanges/kline"
|
||||
)
|
||||
|
||||
// DefaultKey is the fallback key for AddSuccessfulSubscriptions
|
||||
type DefaultKey struct {
|
||||
Channel string
|
||||
Pair currency.Pair
|
||||
Asset asset.Item
|
||||
}
|
||||
// State constants
|
||||
const (
|
||||
InactiveState State = iota
|
||||
SubscribingState
|
||||
SubscribedState
|
||||
ResubscribingState
|
||||
UnsubscribingState
|
||||
UnsubscribedState
|
||||
)
|
||||
|
||||
// Channel constants
|
||||
const (
|
||||
TickerChannel = "ticker"
|
||||
OrderbookChannel = "orderbook"
|
||||
CandlesChannel = "candles"
|
||||
AllOrdersChannel = "allOrders"
|
||||
AllTradesChannel = "allTrades"
|
||||
MyTradesChannel = "myTrades"
|
||||
MyOrdersChannel = "myOrders"
|
||||
)
|
||||
|
||||
// Public errors
|
||||
var (
|
||||
ErrNotFound = errors.New("subscription not found")
|
||||
ErrNotSinglePair = errors.New("only single pair subscriptions expected")
|
||||
ErrInStateAlready = errors.New("subscription already in state")
|
||||
ErrInvalidState = errors.New("invalid subscription state")
|
||||
ErrDuplicate = errors.New("duplicate subscription")
|
||||
)
|
||||
|
||||
// State tracks the status of a subscription channel
|
||||
type State uint8
|
||||
|
||||
const (
|
||||
UnknownState State = iota // UnknownState subscription state is not registered, but doesn't imply Inactive
|
||||
SubscribingState // SubscribingState means channel is in the process of subscribing
|
||||
SubscribedState // SubscribedState means the channel has finished a successful and acknowledged subscription
|
||||
UnsubscribingState // UnsubscribingState means the channel has started to unsubscribe, but not yet confirmed
|
||||
|
||||
TickerChannel = "ticker" // TickerChannel Subscription Type
|
||||
OrderbookChannel = "orderbook" // OrderbookChannel Subscription Type
|
||||
CandlesChannel = "candles" // CandlesChannel Subscription Type
|
||||
AllOrdersChannel = "allOrders" // AllOrdersChannel Subscription Type
|
||||
AllTradesChannel = "allTrades" // AllTradesChannel Subscription Type
|
||||
MyTradesChannel = "myTrades" // MyTradesChannel Subscription Type
|
||||
MyOrdersChannel = "myOrders" // MyOrdersChannel Subscription Type
|
||||
)
|
||||
|
||||
// Subscription container for streaming subscriptions
|
||||
type Subscription struct {
|
||||
Enabled bool `json:"enabled"`
|
||||
Key any `json:"-"`
|
||||
Channel string `json:"channel,omitempty"`
|
||||
Pair currency.Pair `json:"pair,omitempty"`
|
||||
Asset asset.Item `json:"asset,omitempty"`
|
||||
Params map[string]interface{} `json:"params,omitempty"`
|
||||
State State `json:"-"`
|
||||
Interval kline.Interval `json:"interval,omitempty"`
|
||||
Levels int `json:"levels,omitempty"`
|
||||
Authenticated bool `json:"authenticated,omitempty"`
|
||||
Enabled bool `json:"enabled"`
|
||||
Key any `json:"-"`
|
||||
Channel string `json:"channel,omitempty"`
|
||||
Pairs currency.Pairs `json:"pairs,omitempty"`
|
||||
Asset asset.Item `json:"asset,omitempty"`
|
||||
Params map[string]any `json:"params,omitempty"`
|
||||
Interval kline.Interval `json:"interval,omitempty"`
|
||||
Levels int `json:"levels,omitempty"`
|
||||
Authenticated bool `json:"authenticated,omitempty"`
|
||||
state State
|
||||
m sync.RWMutex
|
||||
}
|
||||
|
||||
// MarshalJSON generates a JSON representation of a Subscription, specifically for config writing
|
||||
// The only reason it exists is to avoid having to make Pair a pointer, since that would be generally painful
|
||||
// If Pair becomes a pointer, this method is redundant and should be removed
|
||||
func (s *Subscription) MarshalJSON() ([]byte, error) {
|
||||
// None of the usual type embedding tricks seem to work for not emitting an nil Pair
|
||||
// The embedded type's Pair always fills the empty value
|
||||
type MaybePair struct {
|
||||
Enabled bool `json:"enabled"`
|
||||
Channel string `json:"channel,omitempty"`
|
||||
Asset asset.Item `json:"asset,omitempty"`
|
||||
Params map[string]interface{} `json:"params,omitempty"`
|
||||
Interval kline.Interval `json:"interval,omitempty"`
|
||||
Levels int `json:"levels,omitempty"`
|
||||
Authenticated bool `json:"authenticated,omitempty"`
|
||||
Pair *currency.Pair `json:"pair,omitempty"`
|
||||
}
|
||||
|
||||
k := MaybePair{s.Enabled, s.Channel, s.Asset, s.Params, s.Interval, s.Levels, s.Authenticated, nil}
|
||||
if s.Pair != currency.EMPTYPAIR {
|
||||
k.Pair = &s.Pair
|
||||
}
|
||||
|
||||
return json.Marshal(k)
|
||||
}
|
||||
|
||||
// String implements the Stringer interface for Subscription, giving a human representation of the subscription
|
||||
// String implements Stringer, and aims to informatively and uniquely identify a subscription for errors and information
|
||||
// returns a string of the subscription key by delegating to MatchableKey.String() when possible
|
||||
// If the key is not a MatchableKey then both the key and an ExactKey.String() will be returned; e.g. 1137: spot MyTrades
|
||||
func (s *Subscription) String() string {
|
||||
return fmt.Sprintf("%s %s %s", s.Channel, s.Asset, s.Pair)
|
||||
key := s.EnsureKeyed()
|
||||
s.m.RLock()
|
||||
defer s.m.RUnlock()
|
||||
if k, ok := key.(MatchableKey); ok {
|
||||
return k.String()
|
||||
}
|
||||
return fmt.Sprintf("%v: %s", key, ExactKey{s}.String())
|
||||
}
|
||||
|
||||
// EnsureKeyed sets the default key on a channel if it doesn't have one
|
||||
// Returns key for convenience
|
||||
// State returns the subscription state
|
||||
func (s *Subscription) State() State {
|
||||
s.m.RLock()
|
||||
defer s.m.RUnlock()
|
||||
return s.state
|
||||
}
|
||||
|
||||
// SetState sets the subscription state
|
||||
// Errors if already in that state or the new state is not valid
|
||||
func (s *Subscription) SetState(state State) error {
|
||||
s.m.Lock()
|
||||
defer s.m.Unlock()
|
||||
if state == s.state {
|
||||
return ErrInStateAlready
|
||||
}
|
||||
if state > UnsubscribedState {
|
||||
return ErrInvalidState
|
||||
}
|
||||
s.state = state
|
||||
return nil
|
||||
}
|
||||
|
||||
// SetKey does what it says on the tin safely for concurrency
|
||||
func (s *Subscription) SetKey(key any) {
|
||||
s.m.Lock()
|
||||
defer s.m.Unlock()
|
||||
s.Key = key
|
||||
}
|
||||
|
||||
// EnsureKeyed returns the subscription key
|
||||
// If no key exists then ExactKey will be used
|
||||
func (s *Subscription) EnsureKeyed() any {
|
||||
if s.Key == nil {
|
||||
s.Key = DefaultKey{
|
||||
Channel: s.Channel,
|
||||
Asset: s.Asset,
|
||||
Pair: s.Pair,
|
||||
}
|
||||
// Juggle RLock/WLock to minimize concurrent bottleneck for hottest path
|
||||
s.m.RLock()
|
||||
if s.Key != nil {
|
||||
defer s.m.RUnlock()
|
||||
return s.Key
|
||||
}
|
||||
s.m.RUnlock()
|
||||
s.m.Lock()
|
||||
defer s.m.Unlock()
|
||||
if s.Key == nil { // Ensure race hasn't updated Key whilst we swapped locks
|
||||
s.Key = &ExactKey{s}
|
||||
}
|
||||
return s.Key
|
||||
}
|
||||
|
||||
// Clone returns a copy of a subscription
|
||||
// Key is set to nil, because most Key types contain a pointer to the subscription, and because the clone isn't added to the store yet
|
||||
// Users should allow a default key to be assigned on AddSubscription or can SetKey as necessary
|
||||
func (s *Subscription) Clone() *Subscription {
|
||||
s.m.RLock()
|
||||
c := &Subscription{
|
||||
Key: nil,
|
||||
Enabled: s.Enabled,
|
||||
Channel: s.Channel,
|
||||
Asset: s.Asset,
|
||||
Params: s.Params,
|
||||
Interval: s.Interval,
|
||||
Levels: s.Levels,
|
||||
Authenticated: s.Authenticated,
|
||||
state: s.state,
|
||||
Pairs: s.Pairs,
|
||||
}
|
||||
s.Pairs = slices.Clone(s.Pairs)
|
||||
s.Params = maps.Clone(s.Params)
|
||||
s.m.RUnlock()
|
||||
return c
|
||||
}
|
||||
|
||||
// SetPairs does what it says on the tin safely for concurrency
|
||||
func (s *Subscription) SetPairs(pairs currency.Pairs) {
|
||||
s.m.Lock()
|
||||
s.Pairs = pairs
|
||||
s.m.Unlock()
|
||||
}
|
||||
|
||||
// AddPairs does what it says on the tin safely for concurrency
|
||||
func (s *Subscription) AddPairs(pairs ...currency.Pair) {
|
||||
if len(pairs) == 0 {
|
||||
return
|
||||
}
|
||||
s.m.Lock()
|
||||
for _, p := range pairs {
|
||||
s.Pairs = s.Pairs.Add(p)
|
||||
}
|
||||
s.m.Unlock()
|
||||
}
|
||||
|
||||
@@ -10,39 +10,80 @@ import (
|
||||
"github.com/thrasher-corp/gocryptotrader/exchanges/kline"
|
||||
)
|
||||
|
||||
// TestEnsureKeyed logic test
|
||||
func TestEnsureKeyed(t *testing.T) {
|
||||
t.Parallel()
|
||||
c := Subscription{
|
||||
var (
|
||||
btcusdtPair = currency.NewPair(currency.BTC, currency.USDT)
|
||||
ethusdcPair = currency.NewPair(currency.ETH, currency.USDC)
|
||||
ltcusdcPair = currency.NewPair(currency.LTC, currency.USDC)
|
||||
)
|
||||
|
||||
// TestSubscriptionString exercises the String method
|
||||
func TestSubscriptionString(t *testing.T) {
|
||||
s := &Subscription{
|
||||
Channel: "candles",
|
||||
Asset: asset.Spot,
|
||||
Pair: currency.NewPair(currency.BTC, currency.USDT),
|
||||
}
|
||||
k1, ok := c.EnsureKeyed().(DefaultKey)
|
||||
if assert.True(t, ok, "EnsureKeyed should return a DefaultKey") {
|
||||
assert.Exactly(t, k1, c.Key, "EnsureKeyed should set the same key")
|
||||
assert.Equal(t, k1.Channel, c.Channel, "DefaultKey channel should be correct")
|
||||
assert.Equal(t, k1.Asset, c.Asset, "DefaultKey asset should be correct")
|
||||
assert.Equal(t, k1.Pair, c.Pair, "DefaultKey currency should be correct")
|
||||
}
|
||||
type platypus string
|
||||
c = Subscription{
|
||||
Key: platypus("Gerald"),
|
||||
Channel: "orderbook",
|
||||
Asset: asset.Margin,
|
||||
Pair: currency.NewPair(currency.ETH, currency.USDC),
|
||||
}
|
||||
k2, ok := c.EnsureKeyed().(platypus)
|
||||
if assert.True(t, ok, "EnsureKeyed should return a platypus") {
|
||||
assert.Exactly(t, k2, c.Key, "EnsureKeyed should set the same key")
|
||||
assert.EqualValues(t, "Gerald", k2, "key should have the correct value")
|
||||
Pairs: currency.Pairs{btcusdtPair, ethusdcPair.Format(currency.PairFormat{Delimiter: "/"})},
|
||||
}
|
||||
assert.Equal(t, "candles spot BTC/USDT,ETH/USDC", s.String(), "Subscription String should return correct value")
|
||||
}
|
||||
|
||||
// TestMarshalling logic test
|
||||
func TestMarshaling(t *testing.T) {
|
||||
// TestState exercises the state getter
|
||||
func TestState(t *testing.T) {
|
||||
t.Parallel()
|
||||
j, err := json.Marshal(&Subscription{Channel: CandlesChannel})
|
||||
s := &Subscription{}
|
||||
assert.Equal(t, InactiveState, s.State(), "State should return initial state")
|
||||
s.state = SubscribedState
|
||||
assert.Equal(t, SubscribedState, s.State(), "State should return correct state")
|
||||
}
|
||||
|
||||
// TestSetState exercises the state setter
|
||||
func TestSetState(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
s := &Subscription{state: UnsubscribedState}
|
||||
|
||||
for i := InactiveState; i <= UnsubscribedState; i++ {
|
||||
assert.NoErrorf(t, s.SetState(i), "State should not error setting state %s", i)
|
||||
}
|
||||
assert.ErrorIs(t, s.SetState(UnsubscribedState), ErrInStateAlready, "SetState should error on same state")
|
||||
assert.ErrorIs(t, s.SetState(UnsubscribedState+1), ErrInvalidState, "Setting an invalid state should error")
|
||||
}
|
||||
|
||||
// TestString exercises the Stringer implementation
|
||||
func TestString(t *testing.T) {
|
||||
s := &Subscription{
|
||||
Channel: "candles",
|
||||
Asset: asset.Spot,
|
||||
Pairs: currency.Pairs{btcusdtPair},
|
||||
}
|
||||
_ = s.EnsureKeyed()
|
||||
assert.Equal(t, "candles spot BTC/USDT", s.String(), "String with a MatchableKey")
|
||||
s.Key = 42
|
||||
assert.Equal(t, "42: candles spot BTC/USDT", s.String(), "String with a MatchableKey")
|
||||
}
|
||||
|
||||
// TestEnsureKeyed exercises the key getter and ensures it sets a self-pointer key for non
|
||||
func TestEnsureKeyed(t *testing.T) {
|
||||
t.Parallel()
|
||||
s := &Subscription{}
|
||||
k1, ok := s.EnsureKeyed().(MatchableKey)
|
||||
if assert.True(t, ok, "EnsureKeyed should return a MatchableKey") {
|
||||
assert.Same(t, s, k1.GetSubscription(), "Key should point to the same struct")
|
||||
}
|
||||
type platypus string
|
||||
s = &Subscription{
|
||||
Key: platypus("Gerald"),
|
||||
Channel: "orderbook",
|
||||
}
|
||||
k2 := s.EnsureKeyed()
|
||||
assert.IsType(t, platypus(""), k2, "EnsureKeyed should return a platypus")
|
||||
assert.Equal(t, s.Key, k2, "Key should be the key provided")
|
||||
}
|
||||
|
||||
// TestSubscriptionMarshalling ensures json Marshalling is clean and concise
|
||||
// Since there is no UnmarshalJSON, this just exercises the json field tags of Subscription, and regressions in conciseness
|
||||
func TestSubscriptionMarshaling(t *testing.T) {
|
||||
t.Parallel()
|
||||
j, err := json.Marshal(&Subscription{Key: 42, Channel: CandlesChannel})
|
||||
assert.NoError(t, err, "Marshalling should not error")
|
||||
assert.Equal(t, `{"enabled":false,"channel":"candles"}`, string(j), "Marshalling should be clean and concise")
|
||||
|
||||
@@ -50,11 +91,46 @@ func TestMarshaling(t *testing.T) {
|
||||
assert.NoError(t, err, "Marshalling should not error")
|
||||
assert.Equal(t, `{"enabled":true,"channel":"orderbook","interval":"5m","levels":4}`, string(j), "Marshalling should be clean and concise")
|
||||
|
||||
j, err = json.Marshal(&Subscription{Enabled: true, Channel: OrderbookChannel, Interval: kline.FiveMin, Levels: 4, Pair: currency.NewPair(currency.BTC, currency.USDT)})
|
||||
j, err = json.Marshal(&Subscription{Enabled: true, Channel: OrderbookChannel, Interval: kline.FiveMin, Levels: 4, Pairs: currency.Pairs{currency.NewPair(currency.BTC, currency.USDT)}})
|
||||
assert.NoError(t, err, "Marshalling should not error")
|
||||
assert.Equal(t, `{"enabled":true,"channel":"orderbook","interval":"5m","levels":4,"pair":"BTCUSDT"}`, string(j), "Marshalling should be clean and concise")
|
||||
assert.Equal(t, `{"enabled":true,"channel":"orderbook","pairs":"BTCUSDT","interval":"5m","levels":4}`, string(j), "Marshalling should be clean and concise")
|
||||
|
||||
j, err = json.Marshal(&Subscription{Enabled: true, Channel: MyTradesChannel, Authenticated: true})
|
||||
assert.NoError(t, err, "Marshalling should not error")
|
||||
assert.Equal(t, `{"enabled":true,"channel":"myTrades","authenticated":true}`, string(j), "Marshalling should be clean and concise")
|
||||
}
|
||||
|
||||
// TestClone exercises Clone
|
||||
func TestClone(t *testing.T) {
|
||||
a := &Subscription{
|
||||
Channel: TickerChannel,
|
||||
Interval: kline.OneHour,
|
||||
Pairs: currency.Pairs{btcusdtPair},
|
||||
Params: map[string]any{"a": 42},
|
||||
}
|
||||
a.EnsureKeyed()
|
||||
b := a.Clone()
|
||||
assert.IsType(t, new(Subscription), b, "Clone must return a Subscription pointer")
|
||||
assert.NotSame(t, a, b, "Clone should return a new Subscription")
|
||||
assert.Nil(t, b.Key, "Clone should have a nil key")
|
||||
b.Pairs[0] = ethusdcPair
|
||||
assert.Equal(t, btcusdtPair, a.Pairs[0], "Pairs should be (relatively) deep copied")
|
||||
b.Params["a"] = 12
|
||||
assert.Equal(t, 42, a.Params["a"], "Params should be (relatively) deep copied")
|
||||
a.m.Lock()
|
||||
assert.True(t, b.m.TryLock(), "Clone must use a different Mutex")
|
||||
}
|
||||
|
||||
// TestSetKey exercises SetKey
|
||||
func TestSetKey(t *testing.T) {
|
||||
s := &Subscription{}
|
||||
s.SetKey(14)
|
||||
assert.Equal(t, 14, s.Key, "SetKey should set a key correctly")
|
||||
}
|
||||
|
||||
// TestSetPairs exercises SetPairs
|
||||
func TestSetPairs(t *testing.T) {
|
||||
s := &Subscription{}
|
||||
s.SetPairs(currency.Pairs{btcusdtPair})
|
||||
assert.Equal(t, "BTCUSDT", s.Pairs.Join(), "SetPairs should set a key correctly")
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user