Skip to content

Commit

Permalink
Subscription: Test coverage
Browse files Browse the repository at this point in the history
  • Loading branch information
gbjk committed Feb 24, 2024
1 parent 2ffdc57 commit f6fa149
Show file tree
Hide file tree
Showing 6 changed files with 247 additions and 40 deletions.
13 changes: 13 additions & 0 deletions currency/pairs.go
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,19 @@ func (p Pairs) Strings() []string {
return list
}

// String is a convenience method returning a comma-separated string of uppercase currencies using / as delimiter
func (p Pairs) String() string {
f := PairFormat{
Delimiter: "/",
Uppercase: true,
}
l := make([]string, len(p))
for i, pair := range p {
l[i] = f.Format(pair)
}
return strings.Join(l, ",")
}

// Join returns a comma separated list of currency pairs
func (p Pairs) Join() string {
return strings.Join(p.Strings(), ",")
Expand Down
25 changes: 25 additions & 0 deletions exchanges/subscription/list_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
package subscription

import (
"testing"

"github.com/stretchr/testify/assert"
"github.com/thrasher-corp/gocryptotrader/currency"
"github.com/thrasher-corp/gocryptotrader/exchanges/asset"
)

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")
}
20 changes: 14 additions & 6 deletions exchanges/subscription/store.go
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,9 @@ func (s *Store) Add(sub *Subscription) error {
// Add adds a subscription to the store
// This method provides no locking protection
func (s *Store) add(sub *Subscription) error {
if s.m == nil {
s.m = map[any]*Subscription{}
}
key := sub.EnsureKeyed()
if found := s.get(key); found != nil {
return ErrDuplicate
Expand All @@ -55,9 +58,10 @@ func (s *Store) add(sub *Subscription) error {
}

// Get returns a pointer to a subscription or nil if not found
// If key implements MatchableKey then key.Match will be used
// 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 {
if s == nil || s.m == nil {
return nil
}
s.mu.RLock()
Expand All @@ -69,8 +73,10 @@ func (s *Store) Get(key any) *Subscription {
// 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
// returned subscriptions are implicitly guaranteed to have a Key
func (s *Store) get(key any) *Subscription {
if s.m == nil {
return nil
}
switch v := key.(type) {
case Subscription:
key = v.EnsureKeyed()
Expand All @@ -87,14 +93,16 @@ func (s *Store) get(key any) *Subscription {
}

// Remove removes a subscription from the store
func (s *Store) Remove(sub *Subscription) error {
if s == nil || sub == nil {
// 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 || key == nil {
return common.ErrNilPointer
}
s.mu.Lock()
defer s.mu.Unlock()

if found := s.get(sub); found != nil {
if found := s.get(key); found != nil {
delete(s.m, found.Key)
return nil
}
Expand Down
94 changes: 94 additions & 0 deletions exchanges/subscription/store_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
package subscription

import (
"testing"

"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/thrasher-corp/gocryptotrader/common"
"github.com/thrasher-corp/gocryptotrader/exchanges/asset"
)

// 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})
s, err = NewStoreFromList(l)
assert.ErrorIs(t, err, ErrDuplicate, "Should error correctly on duplicates")
}

// 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.NoError(t, (&Store{}).Add(&Subscription{}), "Should with no map should not error or panic")
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.Same(t, sub.Key, sub, "Add should call EnsureKeyed")
}

// HobbitKey is just a fixture for testing MatchableKey
type HobbitKey int

// Match implements MatchableKey
// Returns true if the key provided is twice as big as the actual sub key
func (f HobbitKey) Match(key any) bool {
i, ok := key.(HobbitKey)
return ok && int(i)*2 == int(f)
}

// TestGet exercises Get and get methods
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 := []*Subscription{
{Channel: OrderbookChannel},
{Channel: TickerChannel},
{Key: 42, Channel: CandlesChannel},
{Key: HobbitKey(24), Channel: CandlesChannel},
}
for _, sub := range exp {
require.NoError(t, s.Add(sub), "Adding subscription must not error)")
}

assert.Nil(t, s.Get(Subscription{Channel: OrderbookChannel, Asset: asset.Spot}), "Should return nil for an unknown sub")
assert.Same(t, exp[0], s.Get(exp[0]), "Should return same pointer for known sub")
assert.Same(t, exp[1], s.Get(Subscription{Channel: TickerChannel}), "Should return pointer for known sub passed-by-value")
assert.Same(t, exp[2], s.Get(42), "Should return pointer for simple key lookup")
assert.Same(t, exp[3], s.Get(HobbitKey(48)), "Should use MatchableKey interface to find subs")
assert.Nil(t, s.Get(HobbitKey(24)), "Should use MatchableKey interface to find subs, therefore not find a HobbitKey 24")
}

// 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{}), ErrNotFound, "Should error correctly when called with no subscription map")
s := NewStore()
require.NoError(t, s.Add(&Subscription{Key: HobbitKey(24), Channel: CandlesChannel}), "Adding subscription must not error")
assert.ErrorIs(t, s.Remove(HobbitKey(24)), ErrNotFound, "Should error correctly when called with a non-matching hobbitkey")
assert.NoError(t, s.Remove(HobbitKey(48)), ErrNotFound, "Should Remove correctly when called matching hobbitkey")
assert.ErrorIs(t, s.Remove(HobbitKey(48)), ErrNotFound, "Should error correctly when called twice on same key")
}
26 changes: 17 additions & 9 deletions exchanges/subscription/subscription.go
Original file line number Diff line number Diff line change
Expand Up @@ -88,8 +88,8 @@ func (s *Subscription) SetState(state State) error {
return nil
}

// EnsureKeyed sets the default key on a channel if it doesn't have one
// Returns key for convenience
// EnsureKeyed returns the subscription key
// If no key exists then a pointer to the subscription itself will be used, since Subscriptions implement MatchableKey
func (s *Subscription) EnsureKeyed() any {
if s.Key == nil {
s.Key = s
Expand All @@ -103,17 +103,25 @@ func (s *Subscription) EnsureKeyed() any {
// 2) >=1 pairs then Subscriptions which contain all the pairs match
// Such that a subscription for all enabled pairs will be matched when seaching for any one pair

Check failure on line 104 in exchanges/subscription/subscription.go

View workflow job for this annotation

GitHub Actions / Spell checker

seaching ==> searching, reaching, teaching
func (s *Subscription) Match(key any) bool {
b, ok := key.(*Subscription)
var b *Subscription
switch v := key.(type) {
case *Subscription:
b = v
case Subscription:
b = &v
default:
return false
}

switch {
case !ok,
s.Channel != b.Channel,
s.Asset != b.Asset,
len(b.Pairs) == 0 && len(s.Pairs) != 0,
case b.Channel != s.Channel,
b.Asset != s.Asset,
// len(b.Pairs) == 0 && len(s.Pairs) == 0: Okay; continue to next non-pairs check
len(b.Pairs) == 0 && len(s.Pairs) != 0,
len(b.Pairs) != 0 && len(s.Pairs) == 0,
len(b.Pairs) != 0 && s.Pairs.ContainsAll(b.Pairs, true) != nil,
s.Levels != b.Levels,
s.Interval != b.Interval:
b.Levels != s.Levels,
b.Interval != s.Interval:
return false
}

Expand Down
109 changes: 84 additions & 25 deletions exchanges/subscription/subscription_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -11,36 +11,67 @@ import (
"github.com/thrasher-corp/gocryptotrader/exchanges/kline"
)

// TestEnsureKeyed logic test
func TestEnsureKeyed(t *testing.T) {
t.Parallel()
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,
Pairs: []currency.Pair{currency.NewPair(currency.BTC, currency.USDT)},
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")
}

// TestState exercises the state getter
func TestState(t *testing.T) {
t.Parallel()
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: UnsubscribingState}

for i := InactiveState; i <= UnsubscribingState; i++ {
assert.NoErrorf(t, s.SetState(i), "State should not error setting state %s", i)
}
assert.ErrorIs(t, s.SetState(UnsubscribingState), ErrInStateAlready, "SetState should error on same state")
assert.ErrorIs(t, s.SetState(UnsubscribingState+1), ErrInvalidState, "Setting an invalid state should error")
}

// 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().(*Subscription)
if assert.True(t, ok, "EnsureKeyed should return a *Subscription") {
assert.Same(t, k1, s, "Key should point to the same struct")
assert.Same(t, s, k1, "Key should point to the same struct")
}
type platypus string
s = &Subscription{
Key: platypus("Gerald"),
Channel: "orderbook",
Asset: asset.Margin,
Pairs: []currency.Pair{currency.NewPair(currency.ETH, currency.USDC)},
}
k2, ok := s.EnsureKeyed().(platypus)
if assert.True(t, ok, "EnsureKeyed should return a platypus") {
assert.Exactly(t, k2, s.Key, "ensureKeyed should set the same key")
assert.EqualValues(t, "Gerald", k2, "key should have the correct value")
}
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")
}

// TestMarshalling logic test
func TestMarshaling(t *testing.T) {
// 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{Channel: CandlesChannel})
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")

Expand All @@ -57,16 +88,44 @@ func TestMarshaling(t *testing.T) {
assert.Equal(t, `{"enabled":true,"channel":"myTrades","authenticated":true}`, string(j), "Marshalling should be clean and concise")
}

// TestSetState tests Subscription state changes
func TestSetState(t *testing.T) {
// TestSubscriptionMatch exercises the Subscription MatchableKey interface implementation
func TestSubscriptionMatch(t *testing.T) {
t.Parallel()
require.Implements(t, (*MatchableKey)(nil), new(Subscription), "Must implement MatchableKey")
s := &Subscription{Channel: TickerChannel}
assert.NotNil(t, s.EnsureKeyed(), "EnsureKeyed should work")
assert.False(t, s.Match(42), "Match should reject an invalid key type")
try := &Subscription{Channel: OrderbookChannel}
require.False(t, s.Match(try), "Gate 1: Match must reject a bad Channel")
try = &Subscription{Channel: TickerChannel}
require.True(t, s.Match(Subscription{Channel: TickerChannel}), "Match must accept a pass-by-value subscription")
require.True(t, s.Match(try), "Gate 1: Match must accept a good Channel")
s.Asset = asset.Spot
require.False(t, s.Match(try), "Gate 2: Match must reject a bad Asset")
try.Asset = asset.Spot
require.True(t, s.Match(try), "Gate 2: Match must accept a good Asset")

s := &Subscription{Key: 42, Channel: "Gophers"}
assert.Equal(t, InactiveState, s.State(), "State should start as unknown")
require.NoError(t, s.SetState(SubscribingState), "SetState should not error")
assert.Equal(t, SubscribingState, s.State(), "State should be set correctly")
assert.ErrorIs(t, s.SetState(SubscribingState), ErrInStateAlready, "SetState should error on same state")
assert.ErrorIs(t, s.SetState(UnsubscribingState+1), ErrInvalidState, "Setting an invalid state should error")
require.NoError(t, s.SetState(UnsubscribingState), "SetState should not error")
assert.Equal(t, UnsubscribingState, s.State(), "State should be set correctly")
s.Pairs = currency.Pairs{btcusdtPair}
require.False(t, s.Match(try), "Gate 3: Match must reject a pair list when searching for no pairs")
try.Pairs = s.Pairs
s.Pairs = nil
require.False(t, s.Match(try), "Gate 4: Match must reject empty Pairs when searching for a list")
s.Pairs = try.Pairs
require.True(t, s.Match(try), "Gate 5: Match must accept matching pairs")
s.Pairs = currency.Pairs{ethusdcPair}
require.False(t, s.Match(try), "Gate 5: Match must reject mismatched pairs")
s.Pairs = currency.Pairs{btcusdtPair, ethusdcPair}
require.True(t, s.Match(try), "Gate 5: Match must accept one of the key pairs matching in sub pairs")
try.Pairs = currency.Pairs{btcusdtPair, ltcusdcPair}
require.False(t, s.Match(try), "Gate 5: Match must reject when sub pair list doesn't contain all key pairs")
s.Pairs = currency.Pairs{btcusdtPair, ethusdcPair, ltcusdcPair}
require.True(t, s.Match(try), "Gate 5: Match must accept all of the key pairs are contained in sub pairs")
s.Levels = 4
require.False(t, s.Match(try), "Gate 6: Match must reject a bad Level")
try.Levels = 4
require.True(t, s.Match(try), "Gate 6: Match must accept a good Level")
s.Interval = kline.FiveMin
require.False(t, s.Match(try), "Gate 7: Match must reject a bad Interval")
try.Interval = kline.FiveMin
require.True(t, s.Match(try), "Gate 7: Match must accept a good Inteval")

Check failure on line 130 in exchanges/subscription/subscription_test.go

View workflow job for this annotation

GitHub Actions / Spell checker

Inteval ==> Interval
}

0 comments on commit f6fa149

Please sign in to comment.